From 051e396a8a5d07340f00e8722b91f2a53e9c5c23 Mon Sep 17 00:00:00 2001
From: Kamal
Date: Wed, 4 Feb 2026 23:26:20 +0530
Subject: [PATCH 001/506] feat: add Slack channel support
---
nanobot/agent/loop.py | 3 +-
nanobot/channels/manager.py | 11 ++
nanobot/channels/slack.py | 205 ++++++++++++++++++++++++++++++++++++
nanobot/cli/commands.py | 9 ++
nanobot/config/schema.py | 21 ++++
pyproject.toml | 1 +
6 files changed, 249 insertions(+), 1 deletion(-)
create mode 100644 nanobot/channels/slack.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index bfe6e89..ac24016 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -220,7 +220,8 @@ class AgentLoop:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
- content=final_content
+ content=final_content,
+ metadata=msg.metadata or {},
)
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 73c3334..d49d3b1 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -55,6 +55,17 @@ class ChannelManager:
logger.info("WhatsApp channel enabled")
except ImportError as e:
logger.warning(f"WhatsApp channel not available: {e}")
+
+ # Slack channel
+ 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(f"Slack channel not available: {e}")
async def start_all(self) -> None:
"""Start WhatsApp channel and the outbound dispatcher."""
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
new file mode 100644
index 0000000..32abe3b
--- /dev/null
+++ b/nanobot/channels/slack.py
@@ -0,0 +1,205 @@
+"""Slack channel implementation using Socket Mode."""
+
+import asyncio
+import re
+from typing import Any
+
+from loguru import logger
+from slack_sdk.socket_mode.aiohttp 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 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(f"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(f"Slack bot connected as {self._bot_user_id}")
+ except Exception as e:
+ logger.warning(f"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(f"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"
+ await self._web_client.chat_postMessage(
+ channel=msg.chat_id,
+ text=msg.content or "",
+ thread_ts=thread_ts if use_thread else None,
+ )
+ except Exception as e:
+ logger.error(f"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 to prevent loops
+ if event.get("subtype") == "bot_message" or 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") or 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="eyes",
+ timestamp=event.get("ts"),
+ )
+ except Exception as e:
+ logger.debug(f"Slack reactions_add failed: {e}")
+
+ 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,
+ }
+ },
+ )
+
+ 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()
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index c2241fb..1dd91a9 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -379,6 +379,15 @@ def channels_status():
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
+ )
+
console.print(table)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 4c34834..3575454 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -19,10 +19,31 @@ class TelegramConfig(BaseModel):
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
+class SlackDMConfig(BaseModel):
+ """Slack DM policy configuration."""
+ enabled: bool = True
+ policy: str = "open" # "open" or "allowlist"
+ allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs
+
+
+class SlackConfig(BaseModel):
+ """Slack channel configuration."""
+ 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
+ group_policy: str = "open" # "open", "mention", "allowlist"
+ group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
+ dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
+
+
class ChannelsConfig(BaseModel):
"""Configuration for chat channels."""
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
+ slack: SlackConfig = Field(default_factory=SlackConfig)
class AgentDefaults(BaseModel):
diff --git a/pyproject.toml b/pyproject.toml
index d578a08..5d4dec9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,7 @@ dependencies = [
"rich>=13.0.0",
"croniter>=2.0.0",
"python-telegram-bot>=21.0",
+ "slack-sdk>=3.26.0",
]
[project.optional-dependencies]
From 5bff24096cb444c994cda9a244d1d2b49dd640fa Mon Sep 17 00:00:00 2001
From: qiupinhua
Date: Thu, 5 Feb 2026 17:39:18 +0800
Subject: [PATCH 002/506] feat: implement OpenAI Codex OAuth login and provider
integration
---
nanobot/auth/__init__.py | 13 +
nanobot/auth/codex_oauth.py | 607 +++++++++++++++++++++
nanobot/cli/commands.py | 104 +++-
nanobot/providers/__init__.py | 3 +-
nanobot/providers/openai_codex_provider.py | 333 +++++++++++
5 files changed, 1041 insertions(+), 19 deletions(-)
create mode 100644 nanobot/auth/__init__.py
create mode 100644 nanobot/auth/codex_oauth.py
create mode 100644 nanobot/providers/openai_codex_provider.py
diff --git a/nanobot/auth/__init__.py b/nanobot/auth/__init__.py
new file mode 100644
index 0000000..e74e1c2
--- /dev/null
+++ b/nanobot/auth/__init__.py
@@ -0,0 +1,13 @@
+"""鉴权相关模块。"""
+
+from nanobot.auth.codex_oauth import (
+ ensure_codex_token_available,
+ get_codex_token,
+ login_codex_oauth_interactive,
+)
+
+__all__ = [
+ "ensure_codex_token_available",
+ "get_codex_token",
+ "login_codex_oauth_interactive",
+]
diff --git a/nanobot/auth/codex_oauth.py b/nanobot/auth/codex_oauth.py
new file mode 100644
index 0000000..0784267
--- /dev/null
+++ b/nanobot/auth/codex_oauth.py
@@ -0,0 +1,607 @@
+"""OpenAI Codex OAuth implementation."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import hashlib
+import json
+import os
+import socket
+import sys
+import threading
+import time
+import urllib.parse
+import webbrowser
+from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from pathlib import Path
+from typing import Any, Callable
+
+import httpx
+
+from nanobot.utils.helpers import ensure_dir, get_data_path
+
+# Fixed parameters (sourced from the official Codex CLI OAuth client).
+CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
+AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
+TOKEN_URL = "https://auth.openai.com/oauth/token"
+REDIRECT_URI = "http://localhost:1455/auth/callback"
+SCOPE = "openid profile email offline_access"
+JWT_CLAIM_PATH = "https://api.openai.com/auth"
+
+DEFAULT_ORIGINATOR = "nanobot"
+TOKEN_FILENAME = "codex.json"
+MANUAL_PROMPT_DELAY_SEC = 3
+SUCCESS_HTML = (
+ ""
+ ""
+ ""
+ ""
+ ""
+ "Authentication successful"
+ ""
+ ""
+ "Authentication successful. Return to your terminal to continue.
"
+ ""
+ ""
+)
+
+
+@dataclass
+class CodexToken:
+ """Codex OAuth token data structure."""
+ access: str
+ refresh: str
+ expires: int
+ account_id: str
+
+
+def _base64url(data: bytes) -> str:
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
+
+
+def _decode_base64url(data: str) -> bytes:
+ padding = "=" * (-len(data) % 4)
+ return base64.urlsafe_b64decode(data + padding)
+
+
+def _generate_pkce() -> tuple[str, str]:
+ verifier = _base64url(os.urandom(32))
+ challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
+ return verifier, challenge
+
+
+def _create_state() -> str:
+ return _base64url(os.urandom(16))
+
+
+def _get_token_path() -> Path:
+ auth_dir = ensure_dir(get_data_path() / "auth")
+ return auth_dir / TOKEN_FILENAME
+
+
+def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]:
+ value = raw.strip()
+ if not value:
+ return None, None
+ try:
+ url = urllib.parse.urlparse(value)
+ qs = urllib.parse.parse_qs(url.query)
+ code = qs.get("code", [None])[0]
+ state = qs.get("state", [None])[0]
+ if code:
+ return code, state
+ except Exception:
+ pass
+
+ if "#" in value:
+ parts = value.split("#", 1)
+ return parts[0] or None, parts[1] or None
+
+ if "code=" in value:
+ qs = urllib.parse.parse_qs(value)
+ return qs.get("code", [None])[0], qs.get("state", [None])[0]
+
+ return value, None
+
+
+def _decode_account_id(access_token: str) -> str:
+ parts = access_token.split(".")
+ if len(parts) != 3:
+ raise ValueError("Invalid JWT token")
+ payload = json.loads(_decode_base64url(parts[1]).decode("utf-8"))
+ auth = payload.get(JWT_CLAIM_PATH) or {}
+ account_id = auth.get("chatgpt_account_id")
+ if not account_id:
+ raise ValueError("Failed to extract account_id from token")
+ return str(account_id)
+
+
+class _OAuthHandler(BaseHTTPRequestHandler):
+ """Local callback HTTP handler."""
+
+ server_version = "NanobotOAuth/1.0"
+ protocol_version = "HTTP/1.1"
+
+ def do_GET(self) -> None: # noqa: N802
+ try:
+ url = urllib.parse.urlparse(self.path)
+ if url.path != "/auth/callback":
+ self.send_response(404)
+ self.end_headers()
+ self.wfile.write(b"Not found")
+ return
+
+ qs = urllib.parse.parse_qs(url.query)
+ code = qs.get("code", [None])[0]
+ state = qs.get("state", [None])[0]
+
+ if state != self.server.expected_state:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b"State mismatch")
+ return
+
+ if not code:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b"Missing code")
+ return
+
+ self.server.code = code
+ try:
+ if getattr(self.server, "on_code", None):
+ self.server.on_code(code)
+ except Exception:
+ pass
+ body = SUCCESS_HTML.encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Connection", "close")
+ self.end_headers()
+ self.wfile.write(body)
+ try:
+ self.wfile.flush()
+ except Exception:
+ pass
+ self.close_connection = True
+ except Exception:
+ self.send_response(500)
+ self.end_headers()
+ self.wfile.write(b"Internal error")
+
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
+ # Suppress default logs to avoid noisy output.
+ return
+
+
+class _OAuthServer(HTTPServer):
+ """OAuth callback server with state."""
+
+ def __init__(
+ self,
+ server_address: tuple[str, int],
+ expected_state: str,
+ on_code: Callable[[str], None] | None = None,
+ ):
+ super().__init__(server_address, _OAuthHandler)
+ self.expected_state = expected_state
+ self.code: str | None = None
+ self.on_code = on_code
+
+
+def _start_local_server(
+ state: str,
+ on_code: Callable[[str], None] | None = None,
+) -> tuple[_OAuthServer | None, str | None]:
+ """Start a local OAuth callback server on the first available localhost address."""
+ try:
+ addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM)
+ except OSError as exc:
+ return None, f"Failed to resolve localhost: {exc}"
+
+ last_error: OSError | None = None
+ for family, _socktype, _proto, _canonname, sockaddr in addrinfos:
+ try:
+ # 兼容 IPv4/IPv6 监听,避免 localhost 解析到 ::1 时收不到回调
+ class _AddrOAuthServer(_OAuthServer):
+ address_family = family
+
+ server = _AddrOAuthServer(sockaddr, state, on_code=on_code)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ return server, None
+ except OSError as exc:
+ last_error = exc
+ continue
+
+ if last_error:
+ return None, f"Local callback server failed to start: {last_error}"
+ return None, "Local callback server failed to start: unknown error"
+
+
+def _exchange_code_for_token(code: str, verifier: str) -> CodexToken:
+ data = {
+ "grant_type": "authorization_code",
+ "client_id": CLIENT_ID,
+ "code": code,
+ "code_verifier": verifier,
+ "redirect_uri": REDIRECT_URI,
+ }
+ with httpx.Client(timeout=30.0) as client:
+ response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ if response.status_code != 200:
+ raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}")
+
+ payload = response.json()
+ access = payload.get("access_token")
+ refresh = payload.get("refresh_token")
+ expires_in = payload.get("expires_in")
+ if not access or not refresh or not isinstance(expires_in, int):
+ raise RuntimeError("Token response missing fields")
+ print("Received access token:", access)
+ account_id = _decode_account_id(access)
+ return CodexToken(
+ access=access,
+ refresh=refresh,
+ expires=int(time.time() * 1000 + expires_in * 1000),
+ account_id=account_id,
+ )
+
+
+async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken:
+ data = {
+ "grant_type": "authorization_code",
+ "client_id": CLIENT_ID,
+ "code": code,
+ "code_verifier": verifier,
+ "redirect_uri": REDIRECT_URI,
+ }
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.post(
+ TOKEN_URL,
+ data=data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ if response.status_code != 200:
+ raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}")
+
+ payload = response.json()
+ access = payload.get("access_token")
+ refresh = payload.get("refresh_token")
+ expires_in = payload.get("expires_in")
+ if not access or not refresh or not isinstance(expires_in, int):
+ raise RuntimeError("Token response missing fields")
+
+ account_id = _decode_account_id(access)
+ return CodexToken(
+ access=access,
+ refresh=refresh,
+ expires=int(time.time() * 1000 + expires_in * 1000),
+ account_id=account_id,
+ )
+
+
+def _refresh_token(refresh_token: str) -> CodexToken:
+ data = {
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": CLIENT_ID,
+ }
+ with httpx.Client(timeout=30.0) as client:
+ response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ if response.status_code != 200:
+ raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}")
+
+ payload = response.json()
+ access = payload.get("access_token")
+ refresh = payload.get("refresh_token")
+ expires_in = payload.get("expires_in")
+ if not access or not refresh or not isinstance(expires_in, int):
+ raise RuntimeError("Token refresh response missing fields")
+
+ account_id = _decode_account_id(access)
+ return CodexToken(
+ access=access,
+ refresh=refresh,
+ expires=int(time.time() * 1000 + expires_in * 1000),
+ account_id=account_id,
+ )
+
+
+def _load_token_file() -> CodexToken | None:
+ path = _get_token_path()
+ if not path.exists():
+ return None
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ return CodexToken(
+ access=data["access"],
+ refresh=data["refresh"],
+ expires=int(data["expires"]),
+ account_id=data["account_id"],
+ )
+ except Exception:
+ return None
+
+
+def _save_token_file(token: CodexToken) -> None:
+ path = _get_token_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(
+ {
+ "access": token.access,
+ "refresh": token.refresh,
+ "expires": token.expires,
+ "account_id": token.account_id,
+ },
+ ensure_ascii=True,
+ indent=2,
+ ),
+ encoding="utf-8",
+ )
+ try:
+ os.chmod(path, 0o600)
+ except Exception:
+ # Ignore permission setting failures.
+ pass
+
+
+def _try_import_codex_cli_token() -> CodexToken | None:
+ codex_path = Path.home() / ".codex" / "auth.json"
+ if not codex_path.exists():
+ return None
+ try:
+ data = json.loads(codex_path.read_text(encoding="utf-8"))
+ tokens = data.get("tokens") or {}
+ access = tokens.get("access_token")
+ refresh = tokens.get("refresh_token")
+ account_id = tokens.get("account_id")
+ if not access or not refresh or not account_id:
+ return None
+ try:
+ mtime = codex_path.stat().st_mtime
+ expires = int(mtime * 1000 + 60 * 60 * 1000)
+ except Exception:
+ expires = int(time.time() * 1000 + 60 * 60 * 1000)
+ token = CodexToken(
+ access=str(access),
+ refresh=str(refresh),
+ expires=expires,
+ account_id=str(account_id),
+ )
+ _save_token_file(token)
+ return token
+ except Exception:
+ return None
+
+
+class _FileLock:
+ """Simple file lock to reduce concurrent refreshes."""
+
+ def __init__(self, path: Path):
+ self._path = path
+ self._fp = None
+
+ def __enter__(self) -> "_FileLock":
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ self._fp = open(self._path, "a+")
+ try:
+ import fcntl
+
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
+ except Exception:
+ # Non-POSIX or failed lock: continue without locking.
+ pass
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ try:
+ import fcntl
+
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
+ except Exception:
+ pass
+ try:
+ if self._fp:
+ self._fp.close()
+ except Exception:
+ pass
+
+
+def get_codex_token() -> CodexToken:
+ """Get an available token (refresh if needed)."""
+ token = _load_token_file() or _try_import_codex_cli_token()
+ if not token:
+ raise RuntimeError("Codex OAuth credentials not found. Please run the login command.")
+
+ # Refresh 60 seconds early.
+ now_ms = int(time.time() * 1000)
+ if token.expires - now_ms > 60 * 1000:
+ return token
+
+ lock_path = _get_token_path().with_suffix(".lock")
+ with _FileLock(lock_path):
+ # Re-read to avoid stale token if another process refreshed it.
+ token = _load_token_file() or token
+ now_ms = int(time.time() * 1000)
+ if token.expires - now_ms > 60 * 1000:
+ return token
+ try:
+ refreshed = _refresh_token(token.refresh)
+ _save_token_file(refreshed)
+ return refreshed
+ except Exception:
+ # If refresh fails, re-read the file to avoid false negatives.
+ latest = _load_token_file()
+ if latest and latest.expires - now_ms > 0:
+ return latest
+ raise
+
+
+def ensure_codex_token_available() -> None:
+ """Ensure a valid token is available; raise if not."""
+ _ = get_codex_token()
+
+
+async def _read_stdin_line() -> str:
+ loop = asyncio.get_running_loop()
+ if hasattr(loop, "add_reader") and sys.stdin:
+ future: asyncio.Future[str] = loop.create_future()
+
+ def _on_readable() -> None:
+ line = sys.stdin.readline()
+ if not future.done():
+ future.set_result(line)
+
+ try:
+ loop.add_reader(sys.stdin, _on_readable)
+ except Exception:
+ return await loop.run_in_executor(None, sys.stdin.readline)
+
+ try:
+ return await future
+ finally:
+ try:
+ loop.remove_reader(sys.stdin)
+ except Exception:
+ pass
+
+ return await loop.run_in_executor(None, sys.stdin.readline)
+
+
+async def _await_manual_input(
+ on_manual_code_input: Callable[[str], None],
+) -> str:
+ await asyncio.sleep(MANUAL_PROMPT_DELAY_SEC)
+ on_manual_code_input("Paste the authorization code (or full redirect URL), or wait for the browser callback:")
+ return await _read_stdin_line()
+
+
+def login_codex_oauth_interactive(
+ on_auth: Callable[[str], None] | None = None,
+ on_prompt: Callable[[str], str] | None = None,
+ on_status: Callable[[str], None] | None = None,
+ on_progress: Callable[[str], None] | None = None,
+ on_manual_code_input: Callable[[str], None] = None,
+ originator: str = DEFAULT_ORIGINATOR,
+) -> CodexToken:
+ """Interactive login flow."""
+ async def _login_async() -> CodexToken:
+ verifier, challenge = _generate_pkce()
+ state = _create_state()
+
+ params = {
+ "response_type": "code",
+ "client_id": CLIENT_ID,
+ "redirect_uri": REDIRECT_URI,
+ "scope": SCOPE,
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ "state": state,
+ "id_token_add_organizations": "true",
+ "codex_cli_simplified_flow": "true",
+ "originator": originator,
+ }
+ url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
+
+ loop = asyncio.get_running_loop()
+ code_future: asyncio.Future[str] = loop.create_future()
+
+ def _notify(code_value: str) -> None:
+ if code_future.done():
+ return
+ loop.call_soon_threadsafe(code_future.set_result, code_value)
+
+ server, server_error = _start_local_server(state, on_code=_notify)
+ if on_auth:
+ on_auth(url)
+ else:
+ webbrowser.open(url)
+
+ if not server and server_error and on_status:
+ on_status(
+ f"Local callback server could not start ({server_error}). "
+ "You will need to paste the callback URL or authorization code."
+ )
+
+ code: str | None = None
+ try:
+ if server:
+ if on_progress and not on_manual_code_input:
+ on_progress("Waiting for browser callback...")
+
+ tasks: list[asyncio.Task[Any]] = []
+ callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120))
+ tasks.append(callback_task)
+ manual_task = asyncio.create_task(_await_manual_input(on_manual_code_input))
+ tasks.append(manual_task)
+
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
+ for task in pending:
+ task.cancel()
+
+ for task in done:
+ try:
+ result = task.result()
+ except asyncio.TimeoutError:
+ result = None
+ if not result:
+ continue
+ if task is manual_task:
+ parsed_code, parsed_state = _parse_authorization_input(result)
+ if parsed_state and parsed_state != state:
+ raise RuntimeError("State validation failed.")
+ code = parsed_code
+ else:
+ code = result
+ if code:
+ break
+
+ if not code:
+ prompt = "Please paste the callback URL or authorization code:"
+ if on_prompt:
+ raw = await loop.run_in_executor(None, on_prompt, prompt)
+ else:
+ raw = await loop.run_in_executor(None, input, prompt)
+ parsed_code, parsed_state = _parse_authorization_input(raw)
+ if parsed_state and parsed_state != state:
+ raise RuntimeError("State validation failed.")
+ code = parsed_code
+
+ if not code:
+ raise RuntimeError("Authorization code not found.")
+
+ if on_progress:
+ on_progress("Exchanging authorization code for tokens...")
+ token = await _exchange_code_for_token_async(code, verifier)
+ _save_token_file(token)
+ return token
+ finally:
+ if server:
+ server.shutdown()
+ server.server_close()
+
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return asyncio.run(_login_async())
+
+ result: list[CodexToken] = []
+ error: list[Exception] = []
+
+ def _runner() -> None:
+ try:
+ result.append(asyncio.run(_login_async()))
+ except Exception as exc:
+ error.append(exc)
+
+ thread = threading.Thread(target=_runner)
+ thread.start()
+ thread.join()
+ if error:
+ raise error[0]
+ return result[0]
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index c2241fb..213f8c5 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -73,6 +73,49 @@ def onboard():
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
+@app.command("login")
+def login(
+ provider: str = typer.Option("openai-codex", "--provider", "-p", help="Auth provider"),
+):
+ """Login to an auth provider (e.g. openai-codex)."""
+ if provider != "openai-codex":
+ console.print(f"[red]Unsupported provider: {provider}[/red]")
+ raise typer.Exit(1)
+
+ from nanobot.auth.codex_oauth import login_codex_oauth_interactive
+
+ def on_auth(url: str) -> None:
+ console.print("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]")
+ console.print(url)
+ try:
+ import webbrowser
+ webbrowser.open(url)
+ except Exception:
+ pass
+
+ def on_status(message: str) -> None:
+ console.print(f"[yellow]{message}[/yellow]")
+
+ def on_progress(message: str) -> None:
+ console.print(f"[dim]{message}[/dim]")
+
+ def on_prompt(message: str) -> str:
+ return typer.prompt(message)
+
+ def on_manual_code_input(message: str) -> None:
+ console.print(f"[cyan]{message}[/cyan]")
+
+ console.print("[green]Starting OpenAI Codex OAuth login...[/green]")
+ login_codex_oauth_interactive(
+ on_auth=on_auth,
+ on_prompt=on_prompt,
+ on_status=on_status,
+ on_progress=on_progress,
+ on_manual_code_input=on_manual_code_input,
+ )
+ console.print("[green]✓ Login successful. Credentials saved.[/green]")
+
+
def _create_workspace_templates(workspace: Path):
@@ -161,6 +204,8 @@ def gateway(
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.providers.litellm_provider import LiteLLMProvider
+ from nanobot.providers.openai_codex_provider import OpenAICodexProvider
+ from nanobot.auth.codex_oauth import ensure_codex_token_available
from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
from nanobot.cron.service import CronService
@@ -183,17 +228,26 @@ def gateway(
api_base = config.get_api_base()
model = config.agents.defaults.model
is_bedrock = model.startswith("bedrock/")
+ is_codex = model.startswith("openai-codex/")
- if not api_key and not is_bedrock:
- console.print("[red]Error: No API key configured.[/red]")
- console.print("Set one in ~/.nanobot/config.json under providers.openrouter.apiKey")
- raise typer.Exit(1)
-
- provider = LiteLLMProvider(
- api_key=api_key,
- api_base=api_base,
- default_model=config.agents.defaults.model
- )
+ if is_codex:
+ try:
+ ensure_codex_token_available()
+ except Exception as e:
+ console.print(f"[red]Error: {e}[/red]")
+ console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]")
+ raise typer.Exit(1)
+ provider = OpenAICodexProvider(default_model=model)
+ else:
+ if not api_key and not is_bedrock:
+ console.print("[red]Error: No API key configured.[/red]")
+ console.print("Set one in ~/.nanobot/config.json under providers.openrouter.apiKey")
+ raise typer.Exit(1)
+ provider = LiteLLMProvider(
+ api_key=api_key,
+ api_base=api_base,
+ default_model=config.agents.defaults.model
+ )
# Create agent
agent = AgentLoop(
@@ -286,6 +340,8 @@ def agent(
from nanobot.config.loader import load_config
from nanobot.bus.queue import MessageBus
from nanobot.providers.litellm_provider import LiteLLMProvider
+ from nanobot.providers.openai_codex_provider import OpenAICodexProvider
+ from nanobot.auth.codex_oauth import ensure_codex_token_available
from nanobot.agent.loop import AgentLoop
config = load_config()
@@ -294,17 +350,29 @@ def agent(
api_base = config.get_api_base()
model = config.agents.defaults.model
is_bedrock = model.startswith("bedrock/")
+ is_codex = model.startswith("openai-codex/")
- if not api_key and not is_bedrock:
- console.print("[red]Error: No API key configured.[/red]")
- raise typer.Exit(1)
+ if is_codex:
+ try:
+ ensure_codex_token_available()
+ except Exception as e:
+ console.print(f"[red]Error: {e}[/red]")
+ console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]")
+ raise typer.Exit(1)
+ else:
+ if not api_key and not is_bedrock:
+ console.print("[red]Error: No API key configured.[/red]")
+ raise typer.Exit(1)
bus = MessageBus()
- provider = LiteLLMProvider(
- api_key=api_key,
- api_base=api_base,
- default_model=config.agents.defaults.model
- )
+ if is_codex:
+ provider = OpenAICodexProvider(default_model=config.agents.defaults.model)
+ else:
+ provider = LiteLLMProvider(
+ api_key=api_key,
+ api_base=api_base,
+ default_model=config.agents.defaults.model
+ )
agent_loop = AgentLoop(
bus=bus,
diff --git a/nanobot/providers/__init__.py b/nanobot/providers/__init__.py
index ceff8fa..b2bb2b9 100644
--- a/nanobot/providers/__init__.py
+++ b/nanobot/providers/__init__.py
@@ -2,5 +2,6 @@
from nanobot.providers.base import LLMProvider, LLMResponse
from nanobot.providers.litellm_provider import LiteLLMProvider
+from nanobot.providers.openai_codex_provider import OpenAICodexProvider
-__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider"]
+__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider", "OpenAICodexProvider"]
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
new file mode 100644
index 0000000..2081180
--- /dev/null
+++ b/nanobot/providers/openai_codex_provider.py
@@ -0,0 +1,333 @@
+"""OpenAI Codex Responses Provider。"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+from typing import Any, AsyncGenerator
+
+import httpx
+
+from nanobot.auth.codex_oauth import get_codex_token
+from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
+
+DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
+DEFAULT_ORIGINATOR = "nanobot"
+
+
+class OpenAICodexProvider(LLMProvider):
+ """使用 Codex OAuth 调用 Responses 接口。"""
+
+ def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
+ super().__init__(api_key=None, api_base=None)
+ self.default_model = default_model
+
+ async def chat(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+ model: str | None = None,
+ max_tokens: int = 4096,
+ temperature: float = 0.7,
+ ) -> LLMResponse:
+ model = model or self.default_model
+ system_prompt, input_items = _convert_messages(messages)
+
+ token = await asyncio.to_thread(get_codex_token)
+ headers = _build_headers(token.account_id, token.access)
+
+ body: dict[str, Any] = {
+ "model": _strip_model_prefix(model),
+ "store": False,
+ "stream": True,
+ "instructions": system_prompt,
+ "input": input_items,
+ "text": {"verbosity": "medium"},
+ "include": ["reasoning.encrypted_content"],
+ "prompt_cache_key": _prompt_cache_key(messages),
+ "tool_choice": "auto",
+ "parallel_tool_calls": True,
+ }
+
+ if tools:
+ body["tools"] = _convert_tools(tools)
+
+ url = _resolve_codex_url(DEFAULT_CODEX_BASE_URL)
+
+ try:
+ async with httpx.AsyncClient(timeout=60.0) as client:
+ try:
+ async with client.stream("POST", url, headers=headers, json=body) as response:
+ if response.status_code != 200:
+ text = await response.aread()
+ raise RuntimeError(
+ _friendly_error(response.status_code, text.decode("utf-8", "ignore"))
+ )
+ content, tool_calls, finish_reason = await _consume_sse(response)
+ return LLMResponse(
+ content=content,
+ tool_calls=tool_calls,
+ finish_reason=finish_reason,
+ )
+ except Exception as e:
+ # 证书校验失败时降级关闭校验(存在安全风险)
+ if "CERTIFICATE_VERIFY_FAILED" not in str(e):
+ raise
+ async with httpx.AsyncClient(timeout=60.0, verify=False) as insecure_client:
+ async with insecure_client.stream("POST", url, headers=headers, json=body) as response:
+ if response.status_code != 200:
+ text = await response.aread()
+ raise RuntimeError(
+ _friendly_error(response.status_code, text.decode("utf-8", "ignore"))
+ )
+ content, tool_calls, finish_reason = await _consume_sse(response)
+ return LLMResponse(
+ content=content,
+ tool_calls=tool_calls,
+ finish_reason=finish_reason,
+ )
+ except Exception as e:
+ return LLMResponse(
+ content=f"Error calling Codex: {str(e)}",
+ finish_reason="error",
+ )
+
+ def get_default_model(self) -> str:
+ return self.default_model
+
+
+def _strip_model_prefix(model: str) -> str:
+ if model.startswith("openai-codex/"):
+ return model.split("/", 1)[1]
+ return model
+
+
+def _resolve_codex_url(base_url: str) -> str:
+ raw = base_url.rstrip("/")
+ if raw.endswith("/codex/responses"):
+ return raw
+ if raw.endswith("/codex"):
+ return f"{raw}/responses"
+ return f"{raw}/codex/responses"
+
+
+def _build_headers(account_id: str, token: str) -> dict[str, str]:
+ return {
+ "Authorization": f"Bearer {token}",
+ "chatgpt-account-id": account_id,
+ "OpenAI-Beta": "responses=experimental",
+ "originator": DEFAULT_ORIGINATOR,
+ "User-Agent": "nanobot (python)",
+ "accept": "text/event-stream",
+ "content-type": "application/json",
+ }
+
+
+def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ # nanobot 工具定义已是 OpenAI function schema
+ converted: list[dict[str, Any]] = []
+ for tool in tools:
+ name = tool.get("name")
+ if not isinstance(name, str) or not name:
+ # 忽略无效工具,避免被 Codex 拒绝
+ continue
+ params = tool.get("parameters") or {}
+ if not isinstance(params, dict):
+ # 参数必须是 JSON Schema 对象
+ params = {}
+ converted.append(
+ {
+ "type": "function",
+ "name": name,
+ "description": tool.get("description") or "",
+ "parameters": params,
+ }
+ )
+ return converted
+
+
+def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
+ system_prompt = ""
+ input_items: list[dict[str, Any]] = []
+
+ for idx, msg in enumerate(messages):
+ role = msg.get("role")
+ content = msg.get("content")
+
+ if role == "system":
+ system_prompt = content if isinstance(content, str) else ""
+ continue
+
+ if role == "user":
+ input_items.append(_convert_user_message(content))
+ continue
+
+ if role == "assistant":
+ # 先处理文本
+ if isinstance(content, str) and content:
+ input_items.append(
+ {
+ "type": "message",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": content}],
+ "status": "completed",
+ "id": f"msg_{idx}",
+ }
+ )
+ # 再处理工具调用
+ for tool_call in msg.get("tool_calls", []) or []:
+ fn = tool_call.get("function") or {}
+ call_id = tool_call.get("id") or f"call_{idx}"
+ item_id = f"fc_{idx}"
+ input_items.append(
+ {
+ "type": "function_call",
+ "id": item_id,
+ "call_id": call_id,
+ "name": fn.get("name"),
+ "arguments": fn.get("arguments") or "{}",
+ }
+ )
+ continue
+
+ if role == "tool":
+ call_id = _extract_call_id(msg.get("tool_call_id"))
+ output_text = content if isinstance(content, str) else json.dumps(content)
+ input_items.append(
+ {
+ "type": "function_call_output",
+ "call_id": call_id,
+ "output": output_text,
+ }
+ )
+ continue
+
+ return system_prompt, input_items
+
+
+def _convert_user_message(content: Any) -> dict[str, Any]:
+ if isinstance(content, str):
+ return {"role": "user", "content": [{"type": "input_text", "text": content}]}
+ if isinstance(content, list):
+ converted: list[dict[str, Any]] = []
+ for item in content:
+ if not isinstance(item, dict):
+ continue
+ if item.get("type") == "text":
+ converted.append({"type": "input_text", "text": item.get("text", "")})
+ elif item.get("type") == "image_url":
+ url = (item.get("image_url") or {}).get("url")
+ if url:
+ converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
+ if converted:
+ return {"role": "user", "content": converted}
+ return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
+
+
+def _extract_call_id(tool_call_id: Any) -> str:
+ if isinstance(tool_call_id, str) and tool_call_id:
+ return tool_call_id.split("|", 1)[0]
+ return "call_0"
+
+
+def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
+ raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+
+async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
+ buffer: list[str] = []
+ async for line in response.aiter_lines():
+ if line == "":
+ if buffer:
+ data_lines = [l[5:].strip() for l in buffer if l.startswith("data:")]
+ buffer = []
+ if not data_lines:
+ continue
+ data = "\n".join(data_lines).strip()
+ if not data or data == "[DONE]":
+ continue
+ try:
+ yield json.loads(data)
+ except Exception:
+ continue
+ continue
+ buffer.append(line)
+
+
+
+
+async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
+ content = ""
+ tool_calls: list[ToolCallRequest] = []
+ tool_call_buffers: dict[str, dict[str, Any]] = {}
+ finish_reason = "stop"
+
+ async for event in _iter_sse(response):
+ event_type = event.get("type")
+ if event_type == "response.output_item.added":
+ item = event.get("item") or {}
+ if item.get("type") == "function_call":
+ call_id = item.get("call_id")
+ if not call_id:
+ continue
+ tool_call_buffers[call_id] = {
+ "id": item.get("id") or "fc_0",
+ "name": item.get("name"),
+ "arguments": item.get("arguments") or "",
+ }
+ elif event_type == "response.output_text.delta":
+ content += event.get("delta") or ""
+ elif event_type == "response.function_call_arguments.delta":
+ call_id = event.get("call_id")
+ if call_id and call_id in tool_call_buffers:
+ tool_call_buffers[call_id]["arguments"] += event.get("delta") or ""
+ elif event_type == "response.function_call_arguments.done":
+ call_id = event.get("call_id")
+ if call_id and call_id in tool_call_buffers:
+ tool_call_buffers[call_id]["arguments"] = event.get("arguments") or ""
+ elif event_type == "response.output_item.done":
+ item = event.get("item") or {}
+ if item.get("type") == "function_call":
+ call_id = item.get("call_id")
+ if not call_id:
+ continue
+ buf = tool_call_buffers.get(call_id) or {}
+ args_raw = buf.get("arguments") or item.get("arguments") or "{}"
+ try:
+ args = json.loads(args_raw)
+ except Exception:
+ args = {"raw": args_raw}
+ tool_calls.append(
+ ToolCallRequest(
+ id=f"{call_id}|{buf.get('id') or item.get('id') or 'fc_0'}",
+ name=buf.get("name") or item.get("name"),
+ arguments=args,
+ )
+ )
+ elif event_type == "response.completed":
+ status = (event.get("response") or {}).get("status")
+ finish_reason = _map_finish_reason(status)
+ elif event_type in {"error", "response.failed"}:
+ raise RuntimeError("Codex response failed")
+
+ return content, tool_calls, finish_reason
+
+
+def _map_finish_reason(status: str | None) -> str:
+ if not status:
+ return "stop"
+ if status == "completed":
+ return "stop"
+ if status == "incomplete":
+ return "length"
+ if status in {"failed", "cancelled"}:
+ return "error"
+ return "stop"
+
+
+def _friendly_error(status_code: int, raw: str) -> str:
+ if status_code == 429:
+ return "ChatGPT 使用额度已达上限或触发限流,请稍后再试。"
+ return f"HTTP {status_code}: {raw}"
From d4e65319eeba616caaf9564abe83a62ebf51435e Mon Sep 17 00:00:00 2001
From: qiupinhua
Date: Thu, 5 Feb 2026 17:53:00 +0800
Subject: [PATCH 003/506] refactor: split codex oauth logic to several files
---
nanobot/auth/__init__.py | 4 +-
nanobot/auth/codex/__init__.py | 15 +
nanobot/auth/codex/constants.py | 25 +
nanobot/auth/codex/flow.py | 312 +++++++++++
nanobot/auth/codex/models.py | 15 +
nanobot/auth/codex/pkce.py | 77 +++
nanobot/auth/codex/server.py | 115 ++++
nanobot/auth/codex/storage.py | 118 ++++
nanobot/auth/codex_oauth.py | 607 ---------------------
nanobot/cli/commands.py | 6 +-
nanobot/providers/openai_codex_provider.py | 75 ++-
11 files changed, 717 insertions(+), 652 deletions(-)
create mode 100644 nanobot/auth/codex/__init__.py
create mode 100644 nanobot/auth/codex/constants.py
create mode 100644 nanobot/auth/codex/flow.py
create mode 100644 nanobot/auth/codex/models.py
create mode 100644 nanobot/auth/codex/pkce.py
create mode 100644 nanobot/auth/codex/server.py
create mode 100644 nanobot/auth/codex/storage.py
delete mode 100644 nanobot/auth/codex_oauth.py
diff --git a/nanobot/auth/__init__.py b/nanobot/auth/__init__.py
index e74e1c2..c74d992 100644
--- a/nanobot/auth/__init__.py
+++ b/nanobot/auth/__init__.py
@@ -1,6 +1,6 @@
-"""鉴权相关模块。"""
+"""Authentication modules."""
-from nanobot.auth.codex_oauth import (
+from nanobot.auth.codex import (
ensure_codex_token_available,
get_codex_token,
login_codex_oauth_interactive,
diff --git a/nanobot/auth/codex/__init__.py b/nanobot/auth/codex/__init__.py
new file mode 100644
index 0000000..707cd4d
--- /dev/null
+++ b/nanobot/auth/codex/__init__.py
@@ -0,0 +1,15 @@
+"""Codex OAuth module."""
+
+from nanobot.auth.codex.flow import (
+ ensure_codex_token_available,
+ get_codex_token,
+ login_codex_oauth_interactive,
+)
+from nanobot.auth.codex.models import CodexToken
+
+__all__ = [
+ "CodexToken",
+ "ensure_codex_token_available",
+ "get_codex_token",
+ "login_codex_oauth_interactive",
+]
diff --git a/nanobot/auth/codex/constants.py b/nanobot/auth/codex/constants.py
new file mode 100644
index 0000000..bbe676a
--- /dev/null
+++ b/nanobot/auth/codex/constants.py
@@ -0,0 +1,25 @@
+"""Codex OAuth constants."""
+
+CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
+AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
+TOKEN_URL = "https://auth.openai.com/oauth/token"
+REDIRECT_URI = "http://localhost:1455/auth/callback"
+SCOPE = "openid profile email offline_access"
+JWT_CLAIM_PATH = "https://api.openai.com/auth"
+
+DEFAULT_ORIGINATOR = "nanobot"
+TOKEN_FILENAME = "codex.json"
+MANUAL_PROMPT_DELAY_SEC = 3
+SUCCESS_HTML = (
+ ""
+ ""
+ ""
+ ""
+ ""
+ "Authentication successful"
+ ""
+ ""
+ "Authentication successful. Return to your terminal to continue.
"
+ ""
+ ""
+)
diff --git a/nanobot/auth/codex/flow.py b/nanobot/auth/codex/flow.py
new file mode 100644
index 0000000..0966327
--- /dev/null
+++ b/nanobot/auth/codex/flow.py
@@ -0,0 +1,312 @@
+"""Codex OAuth login and token management."""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+import threading
+import time
+import urllib.parse
+import webbrowser
+from typing import Any, Callable
+
+import httpx
+
+from nanobot.auth.codex.constants import (
+ AUTHORIZE_URL,
+ CLIENT_ID,
+ DEFAULT_ORIGINATOR,
+ MANUAL_PROMPT_DELAY_SEC,
+ REDIRECT_URI,
+ SCOPE,
+ TOKEN_URL,
+)
+from nanobot.auth.codex.models import CodexToken
+from nanobot.auth.codex.pkce import (
+ _create_state,
+ _decode_account_id,
+ _generate_pkce,
+ _parse_authorization_input,
+ _parse_token_payload,
+)
+from nanobot.auth.codex.server import _start_local_server
+from nanobot.auth.codex.storage import (
+ _FileLock,
+ _get_token_path,
+ _load_token_file,
+ _save_token_file,
+ _try_import_codex_cli_token,
+)
+
+
+def _exchange_code_for_token(code: str, verifier: str) -> CodexToken:
+ data = {
+ "grant_type": "authorization_code",
+ "client_id": CLIENT_ID,
+ "code": code,
+ "code_verifier": verifier,
+ "redirect_uri": REDIRECT_URI,
+ }
+ with httpx.Client(timeout=30.0) as client:
+ response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ if response.status_code != 200:
+ raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}")
+
+ payload = response.json()
+ access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields")
+ print("Received access token:", access)
+ account_id = _decode_account_id(access)
+ return CodexToken(
+ access=access,
+ refresh=refresh,
+ expires=int(time.time() * 1000 + expires_in * 1000),
+ account_id=account_id,
+ )
+
+
+async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken:
+ data = {
+ "grant_type": "authorization_code",
+ "client_id": CLIENT_ID,
+ "code": code,
+ "code_verifier": verifier,
+ "redirect_uri": REDIRECT_URI,
+ }
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.post(
+ TOKEN_URL,
+ data=data,
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
+ )
+ if response.status_code != 200:
+ raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}")
+
+ payload = response.json()
+ access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields")
+
+ account_id = _decode_account_id(access)
+ return CodexToken(
+ access=access,
+ refresh=refresh,
+ expires=int(time.time() * 1000 + expires_in * 1000),
+ account_id=account_id,
+ )
+
+
+def _refresh_token(refresh_token: str) -> CodexToken:
+ data = {
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": CLIENT_ID,
+ }
+ with httpx.Client(timeout=30.0) as client:
+ response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ if response.status_code != 200:
+ raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}")
+
+ payload = response.json()
+ access, refresh, expires_in = _parse_token_payload(payload, "Token refresh response missing fields")
+
+ account_id = _decode_account_id(access)
+ return CodexToken(
+ access=access,
+ refresh=refresh,
+ expires=int(time.time() * 1000 + expires_in * 1000),
+ account_id=account_id,
+ )
+
+
+def get_codex_token() -> CodexToken:
+ """Get an available token (refresh if needed)."""
+ token = _load_token_file() or _try_import_codex_cli_token()
+ if not token:
+ raise RuntimeError("Codex OAuth credentials not found. Please run the login command.")
+
+ # Refresh 60 seconds early.
+ now_ms = int(time.time() * 1000)
+ if token.expires - now_ms > 60 * 1000:
+ return token
+
+ lock_path = _get_token_path().with_suffix(".lock")
+ with _FileLock(lock_path):
+ # Re-read to avoid stale token if another process refreshed it.
+ token = _load_token_file() or token
+ now_ms = int(time.time() * 1000)
+ if token.expires - now_ms > 60 * 1000:
+ return token
+ try:
+ refreshed = _refresh_token(token.refresh)
+ _save_token_file(refreshed)
+ return refreshed
+ except Exception:
+ # If refresh fails, re-read the file to avoid false negatives.
+ latest = _load_token_file()
+ if latest and latest.expires - now_ms > 0:
+ return latest
+ raise
+
+
+def ensure_codex_token_available() -> None:
+ """Ensure a valid token is available; raise if not."""
+ _ = get_codex_token()
+
+
+async def _read_stdin_line() -> str:
+ loop = asyncio.get_running_loop()
+ if hasattr(loop, "add_reader") and sys.stdin:
+ future: asyncio.Future[str] = loop.create_future()
+
+ def _on_readable() -> None:
+ line = sys.stdin.readline()
+ if not future.done():
+ future.set_result(line)
+
+ try:
+ loop.add_reader(sys.stdin, _on_readable)
+ except Exception:
+ return await loop.run_in_executor(None, sys.stdin.readline)
+
+ try:
+ return await future
+ finally:
+ try:
+ loop.remove_reader(sys.stdin)
+ except Exception:
+ pass
+
+ return await loop.run_in_executor(None, sys.stdin.readline)
+
+
+async def _await_manual_input(
+ on_manual_code_input: Callable[[str], None],
+) -> str:
+ await asyncio.sleep(MANUAL_PROMPT_DELAY_SEC)
+ on_manual_code_input("Paste the authorization code (or full redirect URL), or wait for the browser callback:")
+ return await _read_stdin_line()
+
+
+def login_codex_oauth_interactive(
+ on_auth: Callable[[str], None] | None = None,
+ on_prompt: Callable[[str], str] | None = None,
+ on_status: Callable[[str], None] | None = None,
+ on_progress: Callable[[str], None] | None = None,
+ on_manual_code_input: Callable[[str], None] = None,
+ originator: str = DEFAULT_ORIGINATOR,
+) -> CodexToken:
+ """Interactive login flow."""
+
+ async def _login_async() -> CodexToken:
+ verifier, challenge = _generate_pkce()
+ state = _create_state()
+
+ params = {
+ "response_type": "code",
+ "client_id": CLIENT_ID,
+ "redirect_uri": REDIRECT_URI,
+ "scope": SCOPE,
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ "state": state,
+ "id_token_add_organizations": "true",
+ "codex_cli_simplified_flow": "true",
+ "originator": originator,
+ }
+ url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
+
+ loop = asyncio.get_running_loop()
+ code_future: asyncio.Future[str] = loop.create_future()
+
+ def _notify(code_value: str) -> None:
+ if code_future.done():
+ return
+ loop.call_soon_threadsafe(code_future.set_result, code_value)
+
+ server, server_error = _start_local_server(state, on_code=_notify)
+ if on_auth:
+ on_auth(url)
+ else:
+ webbrowser.open(url)
+
+ if not server and server_error and on_status:
+ on_status(
+ f"Local callback server could not start ({server_error}). "
+ "You will need to paste the callback URL or authorization code."
+ )
+
+ code: str | None = None
+ try:
+ if server:
+ if on_progress and not on_manual_code_input:
+ on_progress("Waiting for browser callback...")
+
+ tasks: list[asyncio.Task[Any]] = []
+ callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120))
+ tasks.append(callback_task)
+ manual_task = asyncio.create_task(_await_manual_input(on_manual_code_input))
+ tasks.append(manual_task)
+
+ done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
+ for task in pending:
+ task.cancel()
+
+ for task in done:
+ try:
+ result = task.result()
+ except asyncio.TimeoutError:
+ result = None
+ if not result:
+ continue
+ if task is manual_task:
+ parsed_code, parsed_state = _parse_authorization_input(result)
+ if parsed_state and parsed_state != state:
+ raise RuntimeError("State validation failed.")
+ code = parsed_code
+ else:
+ code = result
+ if code:
+ break
+
+ if not code:
+ prompt = "Please paste the callback URL or authorization code:"
+ if on_prompt:
+ raw = await loop.run_in_executor(None, on_prompt, prompt)
+ else:
+ raw = await loop.run_in_executor(None, input, prompt)
+ parsed_code, parsed_state = _parse_authorization_input(raw)
+ if parsed_state and parsed_state != state:
+ raise RuntimeError("State validation failed.")
+ code = parsed_code
+
+ if not code:
+ raise RuntimeError("Authorization code not found.")
+
+ if on_progress:
+ on_progress("Exchanging authorization code for tokens...")
+ token = await _exchange_code_for_token_async(code, verifier)
+ _save_token_file(token)
+ return token
+ finally:
+ if server:
+ server.shutdown()
+ server.server_close()
+
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return asyncio.run(_login_async())
+
+ result: list[CodexToken] = []
+ error: list[Exception] = []
+
+ def _runner() -> None:
+ try:
+ result.append(asyncio.run(_login_async()))
+ except Exception as exc:
+ error.append(exc)
+
+ thread = threading.Thread(target=_runner)
+ thread.start()
+ thread.join()
+ if error:
+ raise error[0]
+ return result[0]
diff --git a/nanobot/auth/codex/models.py b/nanobot/auth/codex/models.py
new file mode 100644
index 0000000..e3a5f55
--- /dev/null
+++ b/nanobot/auth/codex/models.py
@@ -0,0 +1,15 @@
+"""Codex OAuth data models."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class CodexToken:
+ """Codex OAuth token data structure."""
+
+ access: str
+ refresh: str
+ expires: int
+ account_id: str
diff --git a/nanobot/auth/codex/pkce.py b/nanobot/auth/codex/pkce.py
new file mode 100644
index 0000000..b682386
--- /dev/null
+++ b/nanobot/auth/codex/pkce.py
@@ -0,0 +1,77 @@
+"""PKCE and authorization helpers."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import os
+import urllib.parse
+from typing import Any
+
+from nanobot.auth.codex.constants import JWT_CLAIM_PATH
+
+
+def _base64url(data: bytes) -> str:
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
+
+
+def _decode_base64url(data: str) -> bytes:
+ padding = "=" * (-len(data) % 4)
+ return base64.urlsafe_b64decode(data + padding)
+
+
+def _generate_pkce() -> tuple[str, str]:
+ verifier = _base64url(os.urandom(32))
+ challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
+ return verifier, challenge
+
+
+def _create_state() -> str:
+ return _base64url(os.urandom(16))
+
+
+def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]:
+ value = raw.strip()
+ if not value:
+ return None, None
+ try:
+ url = urllib.parse.urlparse(value)
+ qs = urllib.parse.parse_qs(url.query)
+ code = qs.get("code", [None])[0]
+ state = qs.get("state", [None])[0]
+ if code:
+ return code, state
+ except Exception:
+ pass
+
+ if "#" in value:
+ parts = value.split("#", 1)
+ return parts[0] or None, parts[1] or None
+
+ if "code=" in value:
+ qs = urllib.parse.parse_qs(value)
+ return qs.get("code", [None])[0], qs.get("state", [None])[0]
+
+ return value, None
+
+
+def _decode_account_id(access_token: str) -> str:
+ parts = access_token.split(".")
+ if len(parts) != 3:
+ raise ValueError("Invalid JWT token")
+ payload = json.loads(_decode_base64url(parts[1]).decode("utf-8"))
+ auth = payload.get(JWT_CLAIM_PATH) or {}
+ account_id = auth.get("chatgpt_account_id")
+ if not account_id:
+ raise ValueError("Failed to extract account_id from token")
+ return str(account_id)
+
+
+def _parse_token_payload(payload: dict[str, Any], missing_message: str) -> tuple[str, str, int]:
+ access = payload.get("access_token")
+ refresh = payload.get("refresh_token")
+ expires_in = payload.get("expires_in")
+ if not access or not refresh or not isinstance(expires_in, int):
+ raise RuntimeError(missing_message)
+ return access, refresh, expires_in
diff --git a/nanobot/auth/codex/server.py b/nanobot/auth/codex/server.py
new file mode 100644
index 0000000..f31db19
--- /dev/null
+++ b/nanobot/auth/codex/server.py
@@ -0,0 +1,115 @@
+"""Local OAuth callback server."""
+
+from __future__ import annotations
+
+import socket
+import threading
+import urllib.parse
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from typing import Any, Callable
+
+from nanobot.auth.codex.constants import SUCCESS_HTML
+
+
+class _OAuthHandler(BaseHTTPRequestHandler):
+ """Local callback HTTP handler."""
+
+ server_version = "NanobotOAuth/1.0"
+ protocol_version = "HTTP/1.1"
+
+ def do_GET(self) -> None: # noqa: N802
+ try:
+ url = urllib.parse.urlparse(self.path)
+ if url.path != "/auth/callback":
+ self.send_response(404)
+ self.end_headers()
+ self.wfile.write(b"Not found")
+ return
+
+ qs = urllib.parse.parse_qs(url.query)
+ code = qs.get("code", [None])[0]
+ state = qs.get("state", [None])[0]
+
+ if state != self.server.expected_state:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b"State mismatch")
+ return
+
+ if not code:
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b"Missing code")
+ return
+
+ self.server.code = code
+ try:
+ if getattr(self.server, "on_code", None):
+ self.server.on_code(code)
+ except Exception:
+ pass
+ body = SUCCESS_HTML.encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Connection", "close")
+ self.end_headers()
+ self.wfile.write(body)
+ try:
+ self.wfile.flush()
+ except Exception:
+ pass
+ self.close_connection = True
+ except Exception:
+ self.send_response(500)
+ self.end_headers()
+ self.wfile.write(b"Internal error")
+
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A003
+ # Suppress default logs to avoid noisy output.
+ return
+
+
+class _OAuthServer(HTTPServer):
+ """OAuth callback server with state."""
+
+ def __init__(
+ self,
+ server_address: tuple[str, int],
+ expected_state: str,
+ on_code: Callable[[str], None] | None = None,
+ ):
+ super().__init__(server_address, _OAuthHandler)
+ self.expected_state = expected_state
+ self.code: str | None = None
+ self.on_code = on_code
+
+
+def _start_local_server(
+ state: str,
+ on_code: Callable[[str], None] | None = None,
+) -> tuple[_OAuthServer | None, str | None]:
+ """Start a local OAuth callback server on the first available localhost address."""
+ try:
+ addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM)
+ except OSError as exc:
+ return None, f"Failed to resolve localhost: {exc}"
+
+ last_error: OSError | None = None
+ for family, _socktype, _proto, _canonname, sockaddr in addrinfos:
+ try:
+ # Support IPv4/IPv6 to avoid missing callbacks when localhost resolves to ::1.
+ class _AddrOAuthServer(_OAuthServer):
+ address_family = family
+
+ server = _AddrOAuthServer(sockaddr, state, on_code=on_code)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ return server, None
+ except OSError as exc:
+ last_error = exc
+ continue
+
+ if last_error:
+ return None, f"Local callback server failed to start: {last_error}"
+ return None, "Local callback server failed to start: unknown error"
diff --git a/nanobot/auth/codex/storage.py b/nanobot/auth/codex/storage.py
new file mode 100644
index 0000000..31e5e3d
--- /dev/null
+++ b/nanobot/auth/codex/storage.py
@@ -0,0 +1,118 @@
+"""Token storage helpers."""
+
+from __future__ import annotations
+
+import json
+import os
+import time
+from pathlib import Path
+
+from nanobot.auth.codex.constants import TOKEN_FILENAME
+from nanobot.auth.codex.models import CodexToken
+from nanobot.utils.helpers import ensure_dir, get_data_path
+
+
+def _get_token_path() -> Path:
+ auth_dir = ensure_dir(get_data_path() / "auth")
+ return auth_dir / TOKEN_FILENAME
+
+
+def _load_token_file() -> CodexToken | None:
+ path = _get_token_path()
+ if not path.exists():
+ return None
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ return CodexToken(
+ access=data["access"],
+ refresh=data["refresh"],
+ expires=int(data["expires"]),
+ account_id=data["account_id"],
+ )
+ except Exception:
+ return None
+
+
+def _save_token_file(token: CodexToken) -> None:
+ path = _get_token_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(
+ {
+ "access": token.access,
+ "refresh": token.refresh,
+ "expires": token.expires,
+ "account_id": token.account_id,
+ },
+ ensure_ascii=True,
+ indent=2,
+ ),
+ encoding="utf-8",
+ )
+ try:
+ os.chmod(path, 0o600)
+ except Exception:
+ # Ignore permission setting failures.
+ pass
+
+
+def _try_import_codex_cli_token() -> CodexToken | None:
+ codex_path = Path.home() / ".codex" / "auth.json"
+ if not codex_path.exists():
+ return None
+ try:
+ data = json.loads(codex_path.read_text(encoding="utf-8"))
+ tokens = data.get("tokens") or {}
+ access = tokens.get("access_token")
+ refresh = tokens.get("refresh_token")
+ account_id = tokens.get("account_id")
+ if not access or not refresh or not account_id:
+ return None
+ try:
+ mtime = codex_path.stat().st_mtime
+ expires = int(mtime * 1000 + 60 * 60 * 1000)
+ except Exception:
+ expires = int(time.time() * 1000 + 60 * 60 * 1000)
+ token = CodexToken(
+ access=str(access),
+ refresh=str(refresh),
+ expires=expires,
+ account_id=str(account_id),
+ )
+ _save_token_file(token)
+ return token
+ except Exception:
+ return None
+
+
+class _FileLock:
+ """Simple file lock to reduce concurrent refreshes."""
+
+ def __init__(self, path: Path):
+ self._path = path
+ self._fp = None
+
+ def __enter__(self) -> "_FileLock":
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ self._fp = open(self._path, "a+")
+ try:
+ import fcntl
+
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
+ except Exception:
+ # Non-POSIX or failed lock: continue without locking.
+ pass
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ try:
+ import fcntl
+
+ fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
+ except Exception:
+ pass
+ try:
+ if self._fp:
+ self._fp.close()
+ except Exception:
+ pass
diff --git a/nanobot/auth/codex_oauth.py b/nanobot/auth/codex_oauth.py
deleted file mode 100644
index 0784267..0000000
--- a/nanobot/auth/codex_oauth.py
+++ /dev/null
@@ -1,607 +0,0 @@
-"""OpenAI Codex OAuth implementation."""
-
-from __future__ import annotations
-
-import asyncio
-import base64
-import hashlib
-import json
-import os
-import socket
-import sys
-import threading
-import time
-import urllib.parse
-import webbrowser
-from dataclasses import dataclass
-from http.server import BaseHTTPRequestHandler, HTTPServer
-from pathlib import Path
-from typing import Any, Callable
-
-import httpx
-
-from nanobot.utils.helpers import ensure_dir, get_data_path
-
-# Fixed parameters (sourced from the official Codex CLI OAuth client).
-CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
-AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
-TOKEN_URL = "https://auth.openai.com/oauth/token"
-REDIRECT_URI = "http://localhost:1455/auth/callback"
-SCOPE = "openid profile email offline_access"
-JWT_CLAIM_PATH = "https://api.openai.com/auth"
-
-DEFAULT_ORIGINATOR = "nanobot"
-TOKEN_FILENAME = "codex.json"
-MANUAL_PROMPT_DELAY_SEC = 3
-SUCCESS_HTML = (
- ""
- ""
- ""
- ""
- ""
- "Authentication successful"
- ""
- ""
- "Authentication successful. Return to your terminal to continue.
"
- ""
- ""
-)
-
-
-@dataclass
-class CodexToken:
- """Codex OAuth token data structure."""
- access: str
- refresh: str
- expires: int
- account_id: str
-
-
-def _base64url(data: bytes) -> str:
- return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
-
-
-def _decode_base64url(data: str) -> bytes:
- padding = "=" * (-len(data) % 4)
- return base64.urlsafe_b64decode(data + padding)
-
-
-def _generate_pkce() -> tuple[str, str]:
- verifier = _base64url(os.urandom(32))
- challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
- return verifier, challenge
-
-
-def _create_state() -> str:
- return _base64url(os.urandom(16))
-
-
-def _get_token_path() -> Path:
- auth_dir = ensure_dir(get_data_path() / "auth")
- return auth_dir / TOKEN_FILENAME
-
-
-def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]:
- value = raw.strip()
- if not value:
- return None, None
- try:
- url = urllib.parse.urlparse(value)
- qs = urllib.parse.parse_qs(url.query)
- code = qs.get("code", [None])[0]
- state = qs.get("state", [None])[0]
- if code:
- return code, state
- except Exception:
- pass
-
- if "#" in value:
- parts = value.split("#", 1)
- return parts[0] or None, parts[1] or None
-
- if "code=" in value:
- qs = urllib.parse.parse_qs(value)
- return qs.get("code", [None])[0], qs.get("state", [None])[0]
-
- return value, None
-
-
-def _decode_account_id(access_token: str) -> str:
- parts = access_token.split(".")
- if len(parts) != 3:
- raise ValueError("Invalid JWT token")
- payload = json.loads(_decode_base64url(parts[1]).decode("utf-8"))
- auth = payload.get(JWT_CLAIM_PATH) or {}
- account_id = auth.get("chatgpt_account_id")
- if not account_id:
- raise ValueError("Failed to extract account_id from token")
- return str(account_id)
-
-
-class _OAuthHandler(BaseHTTPRequestHandler):
- """Local callback HTTP handler."""
-
- server_version = "NanobotOAuth/1.0"
- protocol_version = "HTTP/1.1"
-
- def do_GET(self) -> None: # noqa: N802
- try:
- url = urllib.parse.urlparse(self.path)
- if url.path != "/auth/callback":
- self.send_response(404)
- self.end_headers()
- self.wfile.write(b"Not found")
- return
-
- qs = urllib.parse.parse_qs(url.query)
- code = qs.get("code", [None])[0]
- state = qs.get("state", [None])[0]
-
- if state != self.server.expected_state:
- self.send_response(400)
- self.end_headers()
- self.wfile.write(b"State mismatch")
- return
-
- if not code:
- self.send_response(400)
- self.end_headers()
- self.wfile.write(b"Missing code")
- return
-
- self.server.code = code
- try:
- if getattr(self.server, "on_code", None):
- self.server.on_code(code)
- except Exception:
- pass
- body = SUCCESS_HTML.encode("utf-8")
- self.send_response(200)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(body)))
- self.send_header("Connection", "close")
- self.end_headers()
- self.wfile.write(body)
- try:
- self.wfile.flush()
- except Exception:
- pass
- self.close_connection = True
- except Exception:
- self.send_response(500)
- self.end_headers()
- self.wfile.write(b"Internal error")
-
- def log_message(self, format: str, *args: Any) -> None: # noqa: A003
- # Suppress default logs to avoid noisy output.
- return
-
-
-class _OAuthServer(HTTPServer):
- """OAuth callback server with state."""
-
- def __init__(
- self,
- server_address: tuple[str, int],
- expected_state: str,
- on_code: Callable[[str], None] | None = None,
- ):
- super().__init__(server_address, _OAuthHandler)
- self.expected_state = expected_state
- self.code: str | None = None
- self.on_code = on_code
-
-
-def _start_local_server(
- state: str,
- on_code: Callable[[str], None] | None = None,
-) -> tuple[_OAuthServer | None, str | None]:
- """Start a local OAuth callback server on the first available localhost address."""
- try:
- addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM)
- except OSError as exc:
- return None, f"Failed to resolve localhost: {exc}"
-
- last_error: OSError | None = None
- for family, _socktype, _proto, _canonname, sockaddr in addrinfos:
- try:
- # 兼容 IPv4/IPv6 监听,避免 localhost 解析到 ::1 时收不到回调
- class _AddrOAuthServer(_OAuthServer):
- address_family = family
-
- server = _AddrOAuthServer(sockaddr, state, on_code=on_code)
- thread = threading.Thread(target=server.serve_forever, daemon=True)
- thread.start()
- return server, None
- except OSError as exc:
- last_error = exc
- continue
-
- if last_error:
- return None, f"Local callback server failed to start: {last_error}"
- return None, "Local callback server failed to start: unknown error"
-
-
-def _exchange_code_for_token(code: str, verifier: str) -> CodexToken:
- data = {
- "grant_type": "authorization_code",
- "client_id": CLIENT_ID,
- "code": code,
- "code_verifier": verifier,
- "redirect_uri": REDIRECT_URI,
- }
- with httpx.Client(timeout=30.0) as client:
- response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
- if response.status_code != 200:
- raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}")
-
- payload = response.json()
- access = payload.get("access_token")
- refresh = payload.get("refresh_token")
- expires_in = payload.get("expires_in")
- if not access or not refresh or not isinstance(expires_in, int):
- raise RuntimeError("Token response missing fields")
- print("Received access token:", access)
- account_id = _decode_account_id(access)
- return CodexToken(
- access=access,
- refresh=refresh,
- expires=int(time.time() * 1000 + expires_in * 1000),
- account_id=account_id,
- )
-
-
-async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken:
- data = {
- "grant_type": "authorization_code",
- "client_id": CLIENT_ID,
- "code": code,
- "code_verifier": verifier,
- "redirect_uri": REDIRECT_URI,
- }
- async with httpx.AsyncClient(timeout=30.0) as client:
- response = await client.post(
- TOKEN_URL,
- data=data,
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- )
- if response.status_code != 200:
- raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}")
-
- payload = response.json()
- access = payload.get("access_token")
- refresh = payload.get("refresh_token")
- expires_in = payload.get("expires_in")
- if not access or not refresh or not isinstance(expires_in, int):
- raise RuntimeError("Token response missing fields")
-
- account_id = _decode_account_id(access)
- return CodexToken(
- access=access,
- refresh=refresh,
- expires=int(time.time() * 1000 + expires_in * 1000),
- account_id=account_id,
- )
-
-
-def _refresh_token(refresh_token: str) -> CodexToken:
- data = {
- "grant_type": "refresh_token",
- "refresh_token": refresh_token,
- "client_id": CLIENT_ID,
- }
- with httpx.Client(timeout=30.0) as client:
- response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
- if response.status_code != 200:
- raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}")
-
- payload = response.json()
- access = payload.get("access_token")
- refresh = payload.get("refresh_token")
- expires_in = payload.get("expires_in")
- if not access or not refresh or not isinstance(expires_in, int):
- raise RuntimeError("Token refresh response missing fields")
-
- account_id = _decode_account_id(access)
- return CodexToken(
- access=access,
- refresh=refresh,
- expires=int(time.time() * 1000 + expires_in * 1000),
- account_id=account_id,
- )
-
-
-def _load_token_file() -> CodexToken | None:
- path = _get_token_path()
- if not path.exists():
- return None
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- return CodexToken(
- access=data["access"],
- refresh=data["refresh"],
- expires=int(data["expires"]),
- account_id=data["account_id"],
- )
- except Exception:
- return None
-
-
-def _save_token_file(token: CodexToken) -> None:
- path = _get_token_path()
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(
- json.dumps(
- {
- "access": token.access,
- "refresh": token.refresh,
- "expires": token.expires,
- "account_id": token.account_id,
- },
- ensure_ascii=True,
- indent=2,
- ),
- encoding="utf-8",
- )
- try:
- os.chmod(path, 0o600)
- except Exception:
- # Ignore permission setting failures.
- pass
-
-
-def _try_import_codex_cli_token() -> CodexToken | None:
- codex_path = Path.home() / ".codex" / "auth.json"
- if not codex_path.exists():
- return None
- try:
- data = json.loads(codex_path.read_text(encoding="utf-8"))
- tokens = data.get("tokens") or {}
- access = tokens.get("access_token")
- refresh = tokens.get("refresh_token")
- account_id = tokens.get("account_id")
- if not access or not refresh or not account_id:
- return None
- try:
- mtime = codex_path.stat().st_mtime
- expires = int(mtime * 1000 + 60 * 60 * 1000)
- except Exception:
- expires = int(time.time() * 1000 + 60 * 60 * 1000)
- token = CodexToken(
- access=str(access),
- refresh=str(refresh),
- expires=expires,
- account_id=str(account_id),
- )
- _save_token_file(token)
- return token
- except Exception:
- return None
-
-
-class _FileLock:
- """Simple file lock to reduce concurrent refreshes."""
-
- def __init__(self, path: Path):
- self._path = path
- self._fp = None
-
- def __enter__(self) -> "_FileLock":
- self._path.parent.mkdir(parents=True, exist_ok=True)
- self._fp = open(self._path, "a+")
- try:
- import fcntl
-
- fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
- except Exception:
- # Non-POSIX or failed lock: continue without locking.
- pass
- return self
-
- def __exit__(self, exc_type, exc, tb) -> None:
- try:
- import fcntl
-
- fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
- except Exception:
- pass
- try:
- if self._fp:
- self._fp.close()
- except Exception:
- pass
-
-
-def get_codex_token() -> CodexToken:
- """Get an available token (refresh if needed)."""
- token = _load_token_file() or _try_import_codex_cli_token()
- if not token:
- raise RuntimeError("Codex OAuth credentials not found. Please run the login command.")
-
- # Refresh 60 seconds early.
- now_ms = int(time.time() * 1000)
- if token.expires - now_ms > 60 * 1000:
- return token
-
- lock_path = _get_token_path().with_suffix(".lock")
- with _FileLock(lock_path):
- # Re-read to avoid stale token if another process refreshed it.
- token = _load_token_file() or token
- now_ms = int(time.time() * 1000)
- if token.expires - now_ms > 60 * 1000:
- return token
- try:
- refreshed = _refresh_token(token.refresh)
- _save_token_file(refreshed)
- return refreshed
- except Exception:
- # If refresh fails, re-read the file to avoid false negatives.
- latest = _load_token_file()
- if latest and latest.expires - now_ms > 0:
- return latest
- raise
-
-
-def ensure_codex_token_available() -> None:
- """Ensure a valid token is available; raise if not."""
- _ = get_codex_token()
-
-
-async def _read_stdin_line() -> str:
- loop = asyncio.get_running_loop()
- if hasattr(loop, "add_reader") and sys.stdin:
- future: asyncio.Future[str] = loop.create_future()
-
- def _on_readable() -> None:
- line = sys.stdin.readline()
- if not future.done():
- future.set_result(line)
-
- try:
- loop.add_reader(sys.stdin, _on_readable)
- except Exception:
- return await loop.run_in_executor(None, sys.stdin.readline)
-
- try:
- return await future
- finally:
- try:
- loop.remove_reader(sys.stdin)
- except Exception:
- pass
-
- return await loop.run_in_executor(None, sys.stdin.readline)
-
-
-async def _await_manual_input(
- on_manual_code_input: Callable[[str], None],
-) -> str:
- await asyncio.sleep(MANUAL_PROMPT_DELAY_SEC)
- on_manual_code_input("Paste the authorization code (or full redirect URL), or wait for the browser callback:")
- return await _read_stdin_line()
-
-
-def login_codex_oauth_interactive(
- on_auth: Callable[[str], None] | None = None,
- on_prompt: Callable[[str], str] | None = None,
- on_status: Callable[[str], None] | None = None,
- on_progress: Callable[[str], None] | None = None,
- on_manual_code_input: Callable[[str], None] = None,
- originator: str = DEFAULT_ORIGINATOR,
-) -> CodexToken:
- """Interactive login flow."""
- async def _login_async() -> CodexToken:
- verifier, challenge = _generate_pkce()
- state = _create_state()
-
- params = {
- "response_type": "code",
- "client_id": CLIENT_ID,
- "redirect_uri": REDIRECT_URI,
- "scope": SCOPE,
- "code_challenge": challenge,
- "code_challenge_method": "S256",
- "state": state,
- "id_token_add_organizations": "true",
- "codex_cli_simplified_flow": "true",
- "originator": originator,
- }
- url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
-
- loop = asyncio.get_running_loop()
- code_future: asyncio.Future[str] = loop.create_future()
-
- def _notify(code_value: str) -> None:
- if code_future.done():
- return
- loop.call_soon_threadsafe(code_future.set_result, code_value)
-
- server, server_error = _start_local_server(state, on_code=_notify)
- if on_auth:
- on_auth(url)
- else:
- webbrowser.open(url)
-
- if not server and server_error and on_status:
- on_status(
- f"Local callback server could not start ({server_error}). "
- "You will need to paste the callback URL or authorization code."
- )
-
- code: str | None = None
- try:
- if server:
- if on_progress and not on_manual_code_input:
- on_progress("Waiting for browser callback...")
-
- tasks: list[asyncio.Task[Any]] = []
- callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120))
- tasks.append(callback_task)
- manual_task = asyncio.create_task(_await_manual_input(on_manual_code_input))
- tasks.append(manual_task)
-
- done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
- for task in pending:
- task.cancel()
-
- for task in done:
- try:
- result = task.result()
- except asyncio.TimeoutError:
- result = None
- if not result:
- continue
- if task is manual_task:
- parsed_code, parsed_state = _parse_authorization_input(result)
- if parsed_state and parsed_state != state:
- raise RuntimeError("State validation failed.")
- code = parsed_code
- else:
- code = result
- if code:
- break
-
- if not code:
- prompt = "Please paste the callback URL or authorization code:"
- if on_prompt:
- raw = await loop.run_in_executor(None, on_prompt, prompt)
- else:
- raw = await loop.run_in_executor(None, input, prompt)
- parsed_code, parsed_state = _parse_authorization_input(raw)
- if parsed_state and parsed_state != state:
- raise RuntimeError("State validation failed.")
- code = parsed_code
-
- if not code:
- raise RuntimeError("Authorization code not found.")
-
- if on_progress:
- on_progress("Exchanging authorization code for tokens...")
- token = await _exchange_code_for_token_async(code, verifier)
- _save_token_file(token)
- return token
- finally:
- if server:
- server.shutdown()
- server.server_close()
-
- try:
- asyncio.get_running_loop()
- except RuntimeError:
- return asyncio.run(_login_async())
-
- result: list[CodexToken] = []
- error: list[Exception] = []
-
- def _runner() -> None:
- try:
- result.append(asyncio.run(_login_async()))
- except Exception as exc:
- error.append(exc)
-
- thread = threading.Thread(target=_runner)
- thread.start()
- thread.join()
- if error:
- raise error[0]
- return result[0]
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 213f8c5..93be424 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -82,7 +82,7 @@ def login(
console.print(f"[red]Unsupported provider: {provider}[/red]")
raise typer.Exit(1)
- from nanobot.auth.codex_oauth import login_codex_oauth_interactive
+ from nanobot.auth.codex import login_codex_oauth_interactive
def on_auth(url: str) -> None:
console.print("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]")
@@ -205,7 +205,7 @@ def gateway(
from nanobot.bus.queue import MessageBus
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
- from nanobot.auth.codex_oauth import ensure_codex_token_available
+ from nanobot.auth.codex import ensure_codex_token_available
from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
from nanobot.cron.service import CronService
@@ -341,7 +341,7 @@ def agent(
from nanobot.bus.queue import MessageBus
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
- from nanobot.auth.codex_oauth import ensure_codex_token_available
+ from nanobot.auth.codex import ensure_codex_token_available
from nanobot.agent.loop import AgentLoop
config = load_config()
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index 2081180..ec0383c 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -1,4 +1,4 @@
-"""OpenAI Codex Responses Provider。"""
+"""OpenAI Codex Responses Provider."""
from __future__ import annotations
@@ -9,7 +9,7 @@ from typing import Any, AsyncGenerator
import httpx
-from nanobot.auth.codex_oauth import get_codex_token
+from nanobot.auth.codex import get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
@@ -17,7 +17,7 @@ DEFAULT_ORIGINATOR = "nanobot"
class OpenAICodexProvider(LLMProvider):
- """使用 Codex OAuth 调用 Responses 接口。"""
+ """Use Codex OAuth to call the Responses API."""
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
super().__init__(api_key=None, api_base=None)
@@ -56,37 +56,18 @@ class OpenAICodexProvider(LLMProvider):
url = _resolve_codex_url(DEFAULT_CODEX_BASE_URL)
try:
- async with httpx.AsyncClient(timeout=60.0) as client:
- try:
- async with client.stream("POST", url, headers=headers, json=body) as response:
- if response.status_code != 200:
- text = await response.aread()
- raise RuntimeError(
- _friendly_error(response.status_code, text.decode("utf-8", "ignore"))
- )
- content, tool_calls, finish_reason = await _consume_sse(response)
- return LLMResponse(
- content=content,
- tool_calls=tool_calls,
- finish_reason=finish_reason,
- )
- except Exception as e:
- # 证书校验失败时降级关闭校验(存在安全风险)
- if "CERTIFICATE_VERIFY_FAILED" not in str(e):
- raise
- async with httpx.AsyncClient(timeout=60.0, verify=False) as insecure_client:
- async with insecure_client.stream("POST", url, headers=headers, json=body) as response:
- if response.status_code != 200:
- text = await response.aread()
- raise RuntimeError(
- _friendly_error(response.status_code, text.decode("utf-8", "ignore"))
- )
- content, tool_calls, finish_reason = await _consume_sse(response)
- return LLMResponse(
- content=content,
- tool_calls=tool_calls,
- finish_reason=finish_reason,
- )
+ try:
+ content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
+ except Exception as e:
+ # Certificate verification failed, downgrade to disable verification (security risk)
+ if "CERTIFICATE_VERIFY_FAILED" not in str(e):
+ raise
+ content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
+ return LLMResponse(
+ content=content,
+ tool_calls=tool_calls,
+ finish_reason=finish_reason,
+ )
except Exception as e:
return LLMResponse(
content=f"Error calling Codex: {str(e)}",
@@ -124,17 +105,31 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]:
}
+async def _request_codex(
+ url: str,
+ headers: dict[str, str],
+ body: dict[str, Any],
+ verify: bool,
+) -> tuple[str, list[ToolCallRequest], str]:
+ async with httpx.AsyncClient(timeout=60.0, verify=verify) as client:
+ async with client.stream("POST", url, headers=headers, json=body) as response:
+ if response.status_code != 200:
+ text = await response.aread()
+ raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore")))
+ return await _consume_sse(response)
+
+
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
- # nanobot 工具定义已是 OpenAI function schema
+ # Nanobot tool definitions already use the OpenAI function schema.
converted: list[dict[str, Any]] = []
for tool in tools:
name = tool.get("name")
if not isinstance(name, str) or not name:
- # 忽略无效工具,避免被 Codex 拒绝
+ # Skip invalid tools to avoid Codex rejection.
continue
params = tool.get("parameters") or {}
if not isinstance(params, dict):
- # 参数必须是 JSON Schema 对象
+ # Parameters must be a JSON Schema object.
params = {}
converted.append(
{
@@ -164,7 +159,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
continue
if role == "assistant":
- # 先处理文本
+ # Handle text first.
if isinstance(content, str) and content:
input_items.append(
{
@@ -175,7 +170,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
"id": f"msg_{idx}",
}
)
- # 再处理工具调用
+ # Then handle tool calls.
for tool_call in msg.get("tool_calls", []) or []:
fn = tool_call.get("function") or {}
call_id = tool_call.get("id") or f"call_{idx}"
@@ -329,5 +324,5 @@ def _map_finish_reason(status: str | None) -> str:
def _friendly_error(status_code: int, raw: str) -> str:
if status_code == 429:
- return "ChatGPT 使用额度已达上限或触发限流,请稍后再试。"
+ return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
return f"HTTP {status_code}: {raw}"
From 01420f4dd607aea6c8f9881a1b72ac2a814a628a Mon Sep 17 00:00:00 2001
From: pinhua33
Date: Fri, 6 Feb 2026 00:14:31 +0800
Subject: [PATCH 004/506] refactor: remove unused functions and simplify code
---
nanobot/auth/__init__.py | 7 +--
nanobot/auth/codex/__init__.py | 10 +----
nanobot/auth/codex/constants.py | 1 -
nanobot/auth/codex/flow.py | 76 +++++++++------------------------
nanobot/cli/commands.py | 42 ++++--------------
5 files changed, 30 insertions(+), 106 deletions(-)
diff --git a/nanobot/auth/__init__.py b/nanobot/auth/__init__.py
index c74d992..ecdc1dc 100644
--- a/nanobot/auth/__init__.py
+++ b/nanobot/auth/__init__.py
@@ -1,13 +1,8 @@
"""Authentication modules."""
-from nanobot.auth.codex import (
- ensure_codex_token_available,
- get_codex_token,
- login_codex_oauth_interactive,
-)
+from nanobot.auth.codex import get_codex_token, login_codex_oauth_interactive
__all__ = [
- "ensure_codex_token_available",
"get_codex_token",
"login_codex_oauth_interactive",
]
diff --git a/nanobot/auth/codex/__init__.py b/nanobot/auth/codex/__init__.py
index 707cd4d..7a9a39b 100644
--- a/nanobot/auth/codex/__init__.py
+++ b/nanobot/auth/codex/__init__.py
@@ -1,15 +1,7 @@
"""Codex OAuth module."""
-from nanobot.auth.codex.flow import (
- ensure_codex_token_available,
- get_codex_token,
- login_codex_oauth_interactive,
-)
-from nanobot.auth.codex.models import CodexToken
-
+from nanobot.auth.codex.flow import get_codex_token, login_codex_oauth_interactive
__all__ = [
- "CodexToken",
- "ensure_codex_token_available",
"get_codex_token",
"login_codex_oauth_interactive",
]
diff --git a/nanobot/auth/codex/constants.py b/nanobot/auth/codex/constants.py
index bbe676a..7f20aad 100644
--- a/nanobot/auth/codex/constants.py
+++ b/nanobot/auth/codex/constants.py
@@ -9,7 +9,6 @@ JWT_CLAIM_PATH = "https://api.openai.com/auth"
DEFAULT_ORIGINATOR = "nanobot"
TOKEN_FILENAME = "codex.json"
-MANUAL_PROMPT_DELAY_SEC = 3
SUCCESS_HTML = (
""
""
diff --git a/nanobot/auth/codex/flow.py b/nanobot/auth/codex/flow.py
index 0966327..d05feb7 100644
--- a/nanobot/auth/codex/flow.py
+++ b/nanobot/auth/codex/flow.py
@@ -8,7 +8,7 @@ import threading
import time
import urllib.parse
import webbrowser
-from typing import Any, Callable
+from typing import Callable
import httpx
@@ -16,7 +16,6 @@ from nanobot.auth.codex.constants import (
AUTHORIZE_URL,
CLIENT_ID,
DEFAULT_ORIGINATOR,
- MANUAL_PROMPT_DELAY_SEC,
REDIRECT_URI,
SCOPE,
TOKEN_URL,
@@ -39,31 +38,6 @@ from nanobot.auth.codex.storage import (
)
-def _exchange_code_for_token(code: str, verifier: str) -> CodexToken:
- data = {
- "grant_type": "authorization_code",
- "client_id": CLIENT_ID,
- "code": code,
- "code_verifier": verifier,
- "redirect_uri": REDIRECT_URI,
- }
- with httpx.Client(timeout=30.0) as client:
- response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
- if response.status_code != 200:
- raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}")
-
- payload = response.json()
- access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields")
- print("Received access token:", access)
- account_id = _decode_account_id(access)
- return CodexToken(
- access=access,
- refresh=refresh,
- expires=int(time.time() * 1000 + expires_in * 1000),
- account_id=account_id,
- )
-
-
async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken:
data = {
"grant_type": "authorization_code",
@@ -146,11 +120,6 @@ def get_codex_token() -> CodexToken:
raise
-def ensure_codex_token_available() -> None:
- """Ensure a valid token is available; raise if not."""
- _ = get_codex_token()
-
-
async def _read_stdin_line() -> str:
loop = asyncio.get_running_loop()
if hasattr(loop, "add_reader") and sys.stdin:
@@ -177,20 +146,14 @@ async def _read_stdin_line() -> str:
return await loop.run_in_executor(None, sys.stdin.readline)
-async def _await_manual_input(
- on_manual_code_input: Callable[[str], None],
-) -> str:
- await asyncio.sleep(MANUAL_PROMPT_DELAY_SEC)
- on_manual_code_input("Paste the authorization code (or full redirect URL), or wait for the browser callback:")
+async def _await_manual_input(print_fn: Callable[[str], None]) -> str:
+ print_fn("[cyan]Paste the authorization code (or full redirect URL), or wait for the browser callback:[/cyan]")
return await _read_stdin_line()
def login_codex_oauth_interactive(
- on_auth: Callable[[str], None] | None = None,
- on_prompt: Callable[[str], str] | None = None,
- on_status: Callable[[str], None] | None = None,
- on_progress: Callable[[str], None] | None = None,
- on_manual_code_input: Callable[[str], None] = None,
+ print_fn: Callable[[str], None],
+ prompt_fn: Callable[[str], str],
originator: str = DEFAULT_ORIGINATOR,
) -> CodexToken:
"""Interactive login flow."""
@@ -222,27 +185,30 @@ def login_codex_oauth_interactive(
loop.call_soon_threadsafe(code_future.set_result, code_value)
server, server_error = _start_local_server(state, on_code=_notify)
- if on_auth:
- on_auth(url)
- else:
+ print_fn("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]")
+ print_fn(url)
+ try:
webbrowser.open(url)
+ except Exception:
+ pass
- if not server and server_error and on_status:
- on_status(
+ if not server and server_error:
+ print_fn(
+ "[yellow]"
f"Local callback server could not start ({server_error}). "
"You will need to paste the callback URL or authorization code."
+ "[/yellow]"
)
code: str | None = None
try:
if server:
- if on_progress and not on_manual_code_input:
- on_progress("Waiting for browser callback...")
+ print_fn("[dim]Waiting for browser callback...[/dim]")
- tasks: list[asyncio.Task[Any]] = []
+ tasks: list[asyncio.Task[object]] = []
callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120))
tasks.append(callback_task)
- manual_task = asyncio.create_task(_await_manual_input(on_manual_code_input))
+ manual_task = asyncio.create_task(_await_manual_input(print_fn))
tasks.append(manual_task)
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
@@ -268,10 +234,7 @@ def login_codex_oauth_interactive(
if not code:
prompt = "Please paste the callback URL or authorization code:"
- if on_prompt:
- raw = await loop.run_in_executor(None, on_prompt, prompt)
- else:
- raw = await loop.run_in_executor(None, input, prompt)
+ raw = await loop.run_in_executor(None, prompt_fn, prompt)
parsed_code, parsed_state = _parse_authorization_input(raw)
if parsed_state and parsed_state != state:
raise RuntimeError("State validation failed.")
@@ -280,8 +243,7 @@ def login_codex_oauth_interactive(
if not code:
raise RuntimeError("Authorization code not found.")
- if on_progress:
- on_progress("Exchanging authorization code for tokens...")
+ print_fn("[dim]Exchanging authorization code for tokens...[/dim]")
token = await _exchange_code_for_token_async(code, verifier)
_save_token_file(token)
return token
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 93be424..7827103 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -84,37 +84,12 @@ def login(
from nanobot.auth.codex import login_codex_oauth_interactive
- def on_auth(url: str) -> None:
- console.print("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]")
- console.print(url)
- try:
- import webbrowser
- webbrowser.open(url)
- except Exception:
- pass
-
- def on_status(message: str) -> None:
- console.print(f"[yellow]{message}[/yellow]")
-
- def on_progress(message: str) -> None:
- console.print(f"[dim]{message}[/dim]")
-
- def on_prompt(message: str) -> str:
- return typer.prompt(message)
-
- def on_manual_code_input(message: str) -> None:
- console.print(f"[cyan]{message}[/cyan]")
-
console.print("[green]Starting OpenAI Codex OAuth login...[/green]")
login_codex_oauth_interactive(
- on_auth=on_auth,
- on_prompt=on_prompt,
- on_status=on_status,
- on_progress=on_progress,
- on_manual_code_input=on_manual_code_input,
+ print_fn=console.print,
+ prompt_fn=typer.prompt,
)
- console.print("[green]✓ Login successful. Credentials saved.[/green]")
-
+ console.print("[green]Login successful. Credentials saved.[/green]")
@@ -205,7 +180,7 @@ def gateway(
from nanobot.bus.queue import MessageBus
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
- from nanobot.auth.codex import ensure_codex_token_available
+ from nanobot.auth.codex import get_codex_token
from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
from nanobot.cron.service import CronService
@@ -232,7 +207,7 @@ def gateway(
if is_codex:
try:
- ensure_codex_token_available()
+ _ = get_codex_token()
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]")
@@ -341,7 +316,7 @@ def agent(
from nanobot.bus.queue import MessageBus
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
- from nanobot.auth.codex import ensure_codex_token_available
+ from nanobot.auth.codex import get_codex_token
from nanobot.agent.loop import AgentLoop
config = load_config()
@@ -354,7 +329,7 @@ def agent(
if is_codex:
try:
- ensure_codex_token_available()
+ _ = get_codex_token()
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]")
@@ -716,9 +691,10 @@ def status():
console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}")
console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}")
console.print(f"Gemini API: {'[green]✓[/green]' if has_gemini else '[dim]not set[/dim]'}")
- vllm_status = f"[green]✓ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]"
+ vllm_status = f"[green]�?{config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]"
console.print(f"vLLM/Local: {vllm_status}")
if __name__ == "__main__":
app()
+
From f20afc8d2f033f873f57072ed77c2a08ece250a2 Mon Sep 17 00:00:00 2001
From: pinhua33
Date: Fri, 6 Feb 2026 00:39:02 +0800
Subject: [PATCH 005/506] feat: add Codex login status to nanobot status
command
---
nanobot/cli/commands.py | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 7827103..3be4e23 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -1,4 +1,4 @@
-"""CLI commands for nanobot."""
+"""CLI commands for nanobot."""
import asyncio
from pathlib import Path
@@ -667,6 +667,7 @@ def cron_run(
def status():
"""Show nanobot status."""
from nanobot.config.loader import load_config, get_config_path
+ from nanobot.auth.codex import get_codex_token
config_path = get_config_path()
config = load_config()
@@ -694,6 +695,12 @@ def status():
vllm_status = f"[green]�?{config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]"
console.print(f"vLLM/Local: {vllm_status}")
+ try:
+ _ = get_codex_token()
+ codex_status = "[green]logged in[/green]"
+ except Exception:
+ codex_status = "[dim]not logged in[/dim]"
+ console.print(f"Codex Login: {codex_status}")
if __name__ == "__main__":
app()
From b639192e46d7a9ebe94714c270ea1bbace0bb224 Mon Sep 17 00:00:00 2001
From: pinhua33
Date: Fri, 6 Feb 2026 11:52:03 +0800
Subject: [PATCH 006/506] fix: codex tool calling failed unexpectedly
---
nanobot/providers/openai_codex_provider.py | 28 ++++++++++++++++++----
1 file changed, 23 insertions(+), 5 deletions(-)
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index ec0383c..a23becd 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -123,11 +123,19 @@ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
# Nanobot tool definitions already use the OpenAI function schema.
converted: list[dict[str, Any]] = []
for tool in tools:
- name = tool.get("name")
+ fn = tool.get("function") if isinstance(tool, dict) and tool.get("type") == "function" else None
+ if fn and isinstance(fn, dict):
+ name = fn.get("name")
+ desc = fn.get("description")
+ params = fn.get("parameters")
+ else:
+ name = tool.get("name")
+ desc = tool.get("description")
+ params = tool.get("parameters")
if not isinstance(name, str) or not name:
# Skip invalid tools to avoid Codex rejection.
continue
- params = tool.get("parameters") or {}
+ params = params or {}
if not isinstance(params, dict):
# Parameters must be a JSON Schema object.
params = {}
@@ -135,7 +143,7 @@ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
{
"type": "function",
"name": name,
- "description": tool.get("description") or "",
+ "description": desc or "",
"parameters": params,
}
)
@@ -173,8 +181,9 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
# Then handle tool calls.
for tool_call in msg.get("tool_calls", []) or []:
fn = tool_call.get("function") or {}
- call_id = tool_call.get("id") or f"call_{idx}"
- item_id = f"fc_{idx}"
+ call_id, item_id = _split_tool_call_id(tool_call.get("id"))
+ call_id = call_id or f"call_{idx}"
+ item_id = item_id or f"fc_{idx}"
input_items.append(
{
"type": "function_call",
@@ -226,6 +235,15 @@ def _extract_call_id(tool_call_id: Any) -> str:
return "call_0"
+def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
+ if isinstance(tool_call_id, str) and tool_call_id:
+ if "|" in tool_call_id:
+ call_id, item_id = tool_call_id.split("|", 1)
+ return call_id, item_id or None
+ return tool_call_id, None
+ return "call_0", None
+
+
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
From d7b72c8f83b105674852728bea9fb534166579c0 Mon Sep 17 00:00:00 2001
From: cwu
Date: Fri, 6 Feb 2026 12:24:11 -0500
Subject: [PATCH 007/506] Drop unsupported parameters for providers.
---
nanobot/providers/litellm_provider.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 2125b15..b227393 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -64,6 +64,8 @@ class LiteLLMProvider(LLMProvider):
# Disable LiteLLM logging noise
litellm.suppress_debug_info = True
+ # Drop unsupported parameters for providers (e.g., gpt-5 rejects some params)
+ litellm.drop_params = True
async def chat(
self,
From cfe43e49200b809c4b7fc7e9db6ac4912cf23a88 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?=
Date: Sat, 7 Feb 2026 11:03:34 +0800
Subject: [PATCH 008/506] feat(email): add consent-gated IMAP/SMTP email
channel
---
EMAIL_ASSISTANT_E2E_GUIDE.md | 164 ++++++++++++++
nanobot/channels/email.py | 399 +++++++++++++++++++++++++++++++++++
nanobot/channels/manager.py | 11 +
nanobot/config/schema.py | 31 +++
tests/test_email_channel.py | 311 +++++++++++++++++++++++++++
5 files changed, 916 insertions(+)
create mode 100644 EMAIL_ASSISTANT_E2E_GUIDE.md
create mode 100644 nanobot/channels/email.py
create mode 100644 tests/test_email_channel.py
diff --git a/EMAIL_ASSISTANT_E2E_GUIDE.md b/EMAIL_ASSISTANT_E2E_GUIDE.md
new file mode 100644
index 0000000..a72a18c
--- /dev/null
+++ b/EMAIL_ASSISTANT_E2E_GUIDE.md
@@ -0,0 +1,164 @@
+# Nanobot Email Assistant: End-to-End Guide
+
+This guide explains how to run nanobot as a real email assistant with explicit user permission and optional automatic replies.
+
+## 1. What This Feature Does
+
+- Read unread emails via IMAP.
+- Let the agent analyze/respond to email content.
+- Send replies via SMTP.
+- Enforce explicit owner consent before mailbox access.
+- Let you toggle automatic replies on or off.
+
+## 2. Permission Model (Required)
+
+`channels.email.consentGranted` is the hard permission gate.
+
+- `false`: nanobot must not access mailbox content and must not send email.
+- `true`: nanobot may read/send based on other settings.
+
+Only set `consentGranted: true` after the mailbox owner explicitly agrees.
+
+## 3. Auto-Reply Mode
+
+`channels.email.autoReplyEnabled` controls outbound automatic email replies.
+
+- `true`: inbound emails can receive automatic agent replies.
+- `false`: inbound emails can still be read/processed, but automatic replies are skipped.
+
+Use `autoReplyEnabled: false` when you want analysis-only mode.
+
+## 4. Required Account Setup (Gmail Example)
+
+1. Enable 2-Step Verification in Google account security settings.
+2. Create an App Password.
+3. Use this app password for both IMAP and SMTP auth.
+
+Recommended servers:
+- IMAP host/port: `imap.gmail.com:993` (SSL)
+- SMTP host/port: `smtp.gmail.com:587` (STARTTLS)
+
+## 5. Config Example
+
+Edit `~/.nanobot/config.json`:
+
+```json
+{
+ "channels": {
+ "email": {
+ "enabled": true,
+ "consentGranted": true,
+ "imapHost": "imap.gmail.com",
+ "imapPort": 993,
+ "imapUsername": "you@gmail.com",
+ "imapPassword": "${NANOBOT_EMAIL_IMAP_PASSWORD}",
+ "imapMailbox": "INBOX",
+ "imapUseSsl": true,
+ "smtpHost": "smtp.gmail.com",
+ "smtpPort": 587,
+ "smtpUsername": "you@gmail.com",
+ "smtpPassword": "${NANOBOT_EMAIL_SMTP_PASSWORD}",
+ "smtpUseTls": true,
+ "smtpUseSsl": false,
+ "fromAddress": "you@gmail.com",
+ "autoReplyEnabled": true,
+ "pollIntervalSeconds": 30,
+ "markSeen": true,
+ "allowFrom": ["trusted.sender@example.com"]
+ }
+ }
+}
+```
+
+## 6. Set Secrets via Environment Variables
+
+In the same shell before starting gateway:
+
+```bash
+read -s "NANOBOT_EMAIL_IMAP_PASSWORD?IMAP app password: "
+echo
+read -s "NANOBOT_EMAIL_SMTP_PASSWORD?SMTP app password: "
+echo
+export NANOBOT_EMAIL_IMAP_PASSWORD
+export NANOBOT_EMAIL_SMTP_PASSWORD
+```
+
+If you use one app password for both, enter the same value twice.
+
+## 7. Run and Verify
+
+Start:
+
+```bash
+cd /Users/kaijimima1234/Desktop/nanobot
+PYTHONPATH=/Users/kaijimima1234/Desktop/nanobot .venv/bin/nanobot gateway
+```
+
+Check channel status:
+
+```bash
+PYTHONPATH=/Users/kaijimima1234/Desktop/nanobot .venv/bin/nanobot channels status
+```
+
+Expected behavior:
+- `enabled=true + consentGranted=true + autoReplyEnabled=true`: read + auto reply.
+- `enabled=true + consentGranted=true + autoReplyEnabled=false`: read only, no auto reply.
+- `consentGranted=false`: no read, no send.
+
+## 8. Commands You Can Tell Nanobot
+
+Once gateway is running and email consent is enabled:
+
+1. Summarize yesterday's emails:
+
+```text
+summarize my yesterday email
+```
+
+or
+
+```text
+!email summary yesterday
+```
+
+2. Send an email to a friend:
+
+```text
+!email send friend@example.com | Subject here | Body here
+```
+
+or
+
+```text
+send email to friend@example.com subject: Subject here body: Body here
+```
+
+Notes:
+- Sending command always performs a direct send (manual action by you).
+- If `consentGranted` is `false`, send/read are blocked.
+- If `autoReplyEnabled` is `false`, automatic replies are disabled, but direct send command above still works.
+
+## 9. End-to-End Test Plan
+
+1. Send a test email from an allowed sender to your mailbox.
+2. Confirm nanobot receives and processes it.
+3. If `autoReplyEnabled=true`, confirm a reply is delivered.
+4. Set `autoReplyEnabled=false`, send another test email.
+5. Confirm no auto-reply is sent.
+6. Set `consentGranted=false`, send another test email.
+7. Confirm nanobot does not read/send.
+
+## 10. Security Notes
+
+- Never commit real passwords/tokens into git.
+- Prefer environment variables for secrets.
+- Keep `allowFrom` restricted whenever possible.
+- Rotate app passwords immediately if leaked.
+
+## 11. PR Checklist
+
+- [ ] `consentGranted` gating works for read/send.
+- [ ] `autoReplyEnabled` toggle works as documented.
+- [ ] README updated with new fields.
+- [ ] Tests pass (`pytest`).
+- [ ] No real credentials in tracked files.
diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py
new file mode 100644
index 0000000..029c00d
--- /dev/null
+++ b/nanobot/channels/email.py
@@ -0,0 +1,399 @@
+"""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()
+
+ 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(f"Email polling error: {e}")
+
+ await asyncio.sleep(poll_seconds)
+
+ async def stop(self) -> None:
+ """Stop polling loop."""
+ self._running = False
+
+ async def send(self, msg: OutboundMessage) -> None:
+ """Send email via SMTP."""
+ if not self.config.consent_granted:
+ logger.warning("Skip email send: consent_granted is false")
+ return
+
+ force_send = bool((msg.metadata or {}).get("force_send"))
+ if not self.config.auto_reply_enabled and not force_send:
+ logger.info("Skip automatic email reply: auto_reply_enabled is false")
+ return
+
+ if not self.config.smtp_host:
+ logger.warning("Email channel SMTP host not configured")
+ return
+
+ to_addr = msg.chat_id.strip()
+ if not to_addr:
+ logger.warning("Email channel missing recipient address")
+ return
+
+ base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply")
+ subject = self._reply_subject(base_subject)
+ if msg.metadata and isinstance(msg.metadata.get("subject"), str):
+ override = msg.metadata["subject"].strip()
+ if override:
+ subject = override
+
+ email_msg = EmailMessage()
+ email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username
+ email_msg["To"] = to_addr
+ email_msg["Subject"] = subject
+ email_msg.set_content(msg.content or "")
+
+ in_reply_to = self._last_message_id_by_chat.get(to_addr)
+ if in_reply_to:
+ email_msg["In-Reply-To"] = in_reply_to
+ email_msg["References"] = in_reply_to
+
+ try:
+ await asyncio.to_thread(self._smtp_send, email_msg)
+ except Exception as e:
+ logger.error(f"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(f"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)
+
+ if mark_seen:
+ client.store(imap_id, "+FLAGS", "\\Seen")
+ finally:
+ try:
+ client.logout()
+ except Exception:
+ pass
+
+ return messages
+
+ @classmethod
+ def _format_imap_date(cls, value: date) -> str:
+ """Format date for IMAP search (always English month abbreviations)."""
+ month = cls._IMAP_MONTHS[value.month - 1]
+ return f"{value.day:02d}-{month}-{value.year}"
+
+ @staticmethod
+ def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
+ for item in fetched:
+ if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)):
+ return bytes(item[1])
+ return None
+
+ @staticmethod
+ def _extract_uid(fetched: list[Any]) -> str:
+ for item in fetched:
+ if isinstance(item, tuple) and item and isinstance(item[0], (bytes, bytearray)):
+ head = bytes(item[0]).decode("utf-8", errors="ignore")
+ m = re.search(r"UID\s+(\d+)", head)
+ if m:
+ return m.group(1)
+ return ""
+
+ @staticmethod
+ def _decode_header_value(value: str) -> str:
+ if not value:
+ return ""
+ try:
+ return str(make_header(decode_header(value)))
+ except Exception:
+ return value
+
+ @classmethod
+ def _extract_text_body(cls, msg: Any) -> str:
+ """Best-effort extraction of readable body text."""
+ if msg.is_multipart():
+ plain_parts: list[str] = []
+ html_parts: list[str] = []
+ for part in msg.walk():
+ if part.get_content_disposition() == "attachment":
+ continue
+ content_type = part.get_content_type()
+ try:
+ payload = part.get_content()
+ except Exception:
+ payload_bytes = part.get_payload(decode=True) or b""
+ charset = part.get_content_charset() or "utf-8"
+ payload = payload_bytes.decode(charset, errors="replace")
+ if not isinstance(payload, str):
+ continue
+ if content_type == "text/plain":
+ plain_parts.append(payload)
+ elif content_type == "text/html":
+ html_parts.append(payload)
+ if plain_parts:
+ return "\n\n".join(plain_parts).strip()
+ if html_parts:
+ return cls._html_to_text("\n\n".join(html_parts)).strip()
+ return ""
+
+ try:
+ payload = msg.get_content()
+ except Exception:
+ payload_bytes = msg.get_payload(decode=True) or b""
+ charset = msg.get_content_charset() or "utf-8"
+ payload = payload_bytes.decode(charset, errors="replace")
+ if not isinstance(payload, str):
+ return ""
+ if msg.get_content_type() == "text/html":
+ return cls._html_to_text(payload).strip()
+ return payload.strip()
+
+ @staticmethod
+ def _html_to_text(raw_html: str) -> str:
+ text = re.sub(r"<\s*br\s*/?>", "\n", raw_html, flags=re.IGNORECASE)
+ text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE)
+ text = re.sub(r"<[^>]+>", "", text)
+ return html.unescape(text)
+
+ def _reply_subject(self, base_subject: str) -> str:
+ subject = (base_subject or "").strip() or "nanobot reply"
+ prefix = self.config.subject_prefix or "Re: "
+ if subject.lower().startswith("re:"):
+ return subject
+ return f"{prefix}{subject}"
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 64ced48..4a949c8 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -77,6 +77,17 @@ class ChannelManager:
logger.info("Feishu channel enabled")
except ImportError as e:
logger.warning(f"Feishu channel not available: {e}")
+
+ # Email channel
+ 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(f"Email channel not available: {e}")
async def start_all(self) -> None:
"""Start WhatsApp channel and the outbound dispatcher."""
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 9af6ee2..cc512da 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -38,6 +38,36 @@ class DiscordConfig(BaseModel):
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
+class EmailConfig(BaseModel):
+ """Email channel configuration (IMAP inbound + SMTP outbound)."""
+ 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 ChannelsConfig(BaseModel):
"""Configuration for chat channels."""
@@ -45,6 +75,7 @@ class ChannelsConfig(BaseModel):
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
discord: DiscordConfig = Field(default_factory=DiscordConfig)
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
+ email: EmailConfig = Field(default_factory=EmailConfig)
class AgentDefaults(BaseModel):
diff --git a/tests/test_email_channel.py b/tests/test_email_channel.py
new file mode 100644
index 0000000..8b22d8d
--- /dev/null
+++ b/tests/test_email_channel.py
@@ -0,0 +1,311 @@
+from email.message import EmailMessage
+from datetime import date
+
+import pytest
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.email import EmailChannel
+from nanobot.config.schema import EmailConfig
+
+
+def _make_config() -> EmailConfig:
+ return EmailConfig(
+ enabled=True,
+ consent_granted=True,
+ imap_host="imap.example.com",
+ imap_port=993,
+ imap_username="bot@example.com",
+ imap_password="secret",
+ smtp_host="smtp.example.com",
+ smtp_port=587,
+ smtp_username="bot@example.com",
+ smtp_password="secret",
+ mark_seen=True,
+ )
+
+
+def _make_raw_email(
+ from_addr: str = "alice@example.com",
+ subject: str = "Hello",
+ body: str = "This is the body.",
+) -> bytes:
+ msg = EmailMessage()
+ msg["From"] = from_addr
+ msg["To"] = "bot@example.com"
+ msg["Subject"] = subject
+ msg["Message-ID"] = ""
+ msg.set_content(body)
+ return msg.as_bytes()
+
+
+def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None:
+ raw = _make_raw_email(subject="Invoice", body="Please pay")
+
+ class FakeIMAP:
+ def __init__(self) -> None:
+ self.store_calls: list[tuple[bytes, str, str]] = []
+
+ def login(self, _user: str, _pw: str):
+ return "OK", [b"logged in"]
+
+ def select(self, _mailbox: str):
+ return "OK", [b"1"]
+
+ def search(self, *_args):
+ return "OK", [b"1"]
+
+ def fetch(self, _imap_id: bytes, _parts: str):
+ return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"]
+
+ def store(self, imap_id: bytes, op: str, flags: str):
+ self.store_calls.append((imap_id, op, flags))
+ return "OK", [b""]
+
+ def logout(self):
+ return "BYE", [b""]
+
+ fake = FakeIMAP()
+ monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
+
+ channel = EmailChannel(_make_config(), MessageBus())
+ items = channel._fetch_new_messages()
+
+ assert len(items) == 1
+ assert items[0]["sender"] == "alice@example.com"
+ assert items[0]["subject"] == "Invoice"
+ assert "Please pay" in items[0]["content"]
+ assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")]
+
+ # Same UID should be deduped in-process.
+ items_again = channel._fetch_new_messages()
+ assert items_again == []
+
+
+def test_extract_text_body_falls_back_to_html() -> None:
+ msg = EmailMessage()
+ msg["From"] = "alice@example.com"
+ msg["To"] = "bot@example.com"
+ msg["Subject"] = "HTML only"
+ msg.add_alternative("Hello
world
", subtype="html")
+
+ text = EmailChannel._extract_text_body(msg)
+ assert "Hello" in text
+ assert "world" in text
+
+
+@pytest.mark.asyncio
+async def test_start_returns_immediately_without_consent(monkeypatch) -> None:
+ cfg = _make_config()
+ cfg.consent_granted = False
+ channel = EmailChannel(cfg, MessageBus())
+
+ called = {"fetch": False}
+
+ def _fake_fetch():
+ called["fetch"] = True
+ return []
+
+ monkeypatch.setattr(channel, "_fetch_new_messages", _fake_fetch)
+ await channel.start()
+ assert channel.is_running is False
+ assert called["fetch"] is False
+
+
+@pytest.mark.asyncio
+async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None:
+ class FakeSMTP:
+ def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
+ self.timeout = timeout
+ self.started_tls = False
+ self.logged_in = False
+ self.sent_messages: list[EmailMessage] = []
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ def starttls(self, context=None):
+ self.started_tls = True
+
+ def login(self, _user: str, _pw: str):
+ self.logged_in = True
+
+ def send_message(self, msg: EmailMessage):
+ self.sent_messages.append(msg)
+
+ fake_instances: list[FakeSMTP] = []
+
+ def _smtp_factory(host: str, port: int, timeout: int = 30):
+ instance = FakeSMTP(host, port, timeout=timeout)
+ fake_instances.append(instance)
+ return instance
+
+ monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
+
+ channel = EmailChannel(_make_config(), MessageBus())
+ channel._last_subject_by_chat["alice@example.com"] = "Invoice #42"
+ channel._last_message_id_by_chat["alice@example.com"] = ""
+
+ await channel.send(
+ OutboundMessage(
+ channel="email",
+ chat_id="alice@example.com",
+ content="Acknowledged.",
+ )
+ )
+
+ assert len(fake_instances) == 1
+ smtp = fake_instances[0]
+ assert smtp.started_tls is True
+ assert smtp.logged_in is True
+ assert len(smtp.sent_messages) == 1
+ sent = smtp.sent_messages[0]
+ assert sent["Subject"] == "Re: Invoice #42"
+ assert sent["To"] == "alice@example.com"
+ assert sent["In-Reply-To"] == ""
+
+
+@pytest.mark.asyncio
+async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None:
+ class FakeSMTP:
+ def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
+ self.sent_messages: list[EmailMessage] = []
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ def starttls(self, context=None):
+ return None
+
+ def login(self, _user: str, _pw: str):
+ return None
+
+ def send_message(self, msg: EmailMessage):
+ self.sent_messages.append(msg)
+
+ fake_instances: list[FakeSMTP] = []
+
+ def _smtp_factory(host: str, port: int, timeout: int = 30):
+ instance = FakeSMTP(host, port, timeout=timeout)
+ fake_instances.append(instance)
+ return instance
+
+ monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
+
+ cfg = _make_config()
+ cfg.auto_reply_enabled = False
+ channel = EmailChannel(cfg, MessageBus())
+ await channel.send(
+ OutboundMessage(
+ channel="email",
+ chat_id="alice@example.com",
+ content="Should not send.",
+ )
+ )
+ assert fake_instances == []
+
+ await channel.send(
+ OutboundMessage(
+ channel="email",
+ chat_id="alice@example.com",
+ content="Force send.",
+ metadata={"force_send": True},
+ )
+ )
+ assert len(fake_instances) == 1
+ assert len(fake_instances[0].sent_messages) == 1
+
+
+@pytest.mark.asyncio
+async def test_send_skips_when_consent_not_granted(monkeypatch) -> None:
+ class FakeSMTP:
+ def __init__(self, _host: str, _port: int, timeout: int = 30) -> None:
+ self.sent_messages: list[EmailMessage] = []
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ def starttls(self, context=None):
+ return None
+
+ def login(self, _user: str, _pw: str):
+ return None
+
+ def send_message(self, msg: EmailMessage):
+ self.sent_messages.append(msg)
+
+ called = {"smtp": False}
+
+ def _smtp_factory(host: str, port: int, timeout: int = 30):
+ called["smtp"] = True
+ return FakeSMTP(host, port, timeout=timeout)
+
+ monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory)
+
+ cfg = _make_config()
+ cfg.consent_granted = False
+ channel = EmailChannel(cfg, MessageBus())
+ await channel.send(
+ OutboundMessage(
+ channel="email",
+ chat_id="alice@example.com",
+ content="Should not send.",
+ metadata={"force_send": True},
+ )
+ )
+ assert called["smtp"] is False
+
+
+def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(monkeypatch) -> None:
+ raw = _make_raw_email(subject="Status", body="Yesterday update")
+
+ class FakeIMAP:
+ def __init__(self) -> None:
+ self.search_args = None
+ self.store_calls: list[tuple[bytes, str, str]] = []
+
+ def login(self, _user: str, _pw: str):
+ return "OK", [b"logged in"]
+
+ def select(self, _mailbox: str):
+ return "OK", [b"1"]
+
+ def search(self, *_args):
+ self.search_args = _args
+ return "OK", [b"5"]
+
+ def fetch(self, _imap_id: bytes, _parts: str):
+ return "OK", [(b"5 (UID 999 BODY[] {200})", raw), b")"]
+
+ def store(self, imap_id: bytes, op: str, flags: str):
+ self.store_calls.append((imap_id, op, flags))
+ return "OK", [b""]
+
+ def logout(self):
+ return "BYE", [b""]
+
+ fake = FakeIMAP()
+ monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake)
+
+ channel = EmailChannel(_make_config(), MessageBus())
+ items = channel.fetch_messages_between_dates(
+ start_date=date(2026, 2, 6),
+ end_date=date(2026, 2, 7),
+ limit=10,
+ )
+
+ assert len(items) == 1
+ assert items[0]["subject"] == "Status"
+ # search(None, "SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026")
+ assert fake.search_args is not None
+ assert fake.search_args[1:] == ("SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026")
+ assert fake.store_calls == []
From 438ec66fd8134148308db7bcffd45b2e830157cf Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Feb 2026 18:15:18 +0000
Subject: [PATCH 009/506] docs: v0.1.3.post5 release news
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 8a15892..a1ea905 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,10 @@
## 📢 News
+- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
-- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
+- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
From 3c8eadffed9be5ac405bb751fedf9e09f7805860 Mon Sep 17 00:00:00 2001
From: Vincent Wu
Date: Sun, 8 Feb 2026 03:55:24 +0800
Subject: [PATCH 010/506] feat: add MiniMax provider support via LiteLLM
---
README.md | 1 +
nanobot/cli/commands.py | 2 ++
nanobot/config/schema.py | 6 ++++--
nanobot/providers/litellm_provider.py | 6 +++++-
4 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 8a15892..c6d1c2c 100644
--- a/README.md
+++ b/README.md
@@ -352,6 +352,7 @@ Config file: `~/.nanobot/config.json`
| `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 19e62e9..06a3d4c 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -640,6 +640,7 @@ def status():
has_openai = bool(config.providers.openai.api_key)
has_gemini = bool(config.providers.gemini.api_key)
has_zhipu = bool(config.providers.zhipu.api_key)
+ has_minimax = bool(config.providers.minimax.api_key)
has_vllm = bool(config.providers.vllm.api_base)
has_aihubmix = bool(config.providers.aihubmix.api_key)
@@ -648,6 +649,7 @@ def status():
console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}")
console.print(f"Gemini API: {'[green]✓[/green]' if has_gemini else '[dim]not set[/dim]'}")
console.print(f"Zhipu AI API: {'[green]✓[/green]' if has_zhipu else '[dim]not set[/dim]'}")
+ console.print(f"MiniMax API: {'[green]✓[/green]' if has_minimax else '[dim]not set[/dim]'}")
console.print(f"AiHubMix API: {'[green]✓[/green]' if has_aihubmix else '[dim]not set[/dim]'}")
vllm_status = f"[green]✓ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]"
console.print(f"vLLM/Local: {vllm_status}")
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 7724288..b4e14ed 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -80,6 +80,7 @@ class ProvidersConfig(BaseModel):
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
@@ -139,14 +140,15 @@ class Config(BaseSettings):
"openai": p.openai, "gpt": p.openai, "gemini": p.gemini,
"zhipu": p.zhipu, "glm": p.zhipu, "zai": p.zhipu,
"dashscope": p.dashscope, "qwen": p.dashscope,
- "groq": p.groq, "moonshot": p.moonshot, "kimi": p.moonshot, "vllm": p.vllm,
+ "groq": p.groq, "moonshot": p.moonshot, "kimi": p.moonshot,
+ "minimax": p.minimax, "vllm": p.vllm,
}
for kw, provider in keyword_map.items():
if kw in model and provider.api_key:
return provider
# Fallback: gateways first (can serve any model), then specific providers
all_providers = [p.openrouter, p.aihubmix, p.anthropic, p.openai, p.deepseek,
- p.gemini, p.zhipu, p.dashscope, p.moonshot, p.vllm, p.groq]
+ p.gemini, p.zhipu, p.dashscope, p.moonshot, p.minimax, p.vllm, p.groq]
return next((pr for pr in all_providers if pr.api_key), None)
def get_api_key(self, model: str | None = None) -> str | None:
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 7a52e7c..91156c2 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -13,7 +13,7 @@ class LiteLLMProvider(LLMProvider):
"""
LLM provider using LiteLLM for multi-provider support.
- Supports OpenRouter, Anthropic, OpenAI, Gemini, and many other providers through
+ Supports OpenRouter, Anthropic, OpenAI, Gemini, MiniMax, and many other providers through
a unified interface.
"""
@@ -69,6 +69,9 @@ class LiteLLMProvider(LLMProvider):
elif "moonshot" in default_model or "kimi" in default_model:
os.environ.setdefault("MOONSHOT_API_KEY", api_key)
os.environ.setdefault("MOONSHOT_API_BASE", api_base or "https://api.moonshot.cn/v1")
+ elif "minimax" in default_model.lower():
+ os.environ.setdefault("MINIMAX_API_KEY", api_key)
+ os.environ.setdefault("MINIMAX_API_BASE", api_base or "https://api.minimax.io/v1")
if api_base:
litellm.api_base = api_base
@@ -105,6 +108,7 @@ class LiteLLMProvider(LLMProvider):
(("glm", "zhipu"), "zai", ("zhipu/", "zai/", "openrouter/", "hosted_vllm/")),
(("qwen", "dashscope"), "dashscope", ("dashscope/", "openrouter/")),
(("moonshot", "kimi"), "moonshot", ("moonshot/", "openrouter/")),
+ (("minimax",), "minimax", ("minimax/", "openrouter/")),
(("gemini",), "gemini", ("gemini/",)),
]
model_lower = model.lower()
From 8b1ef77970a4b8634dadfc1560a20adba3934c01 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?=
Date: Sun, 8 Feb 2026 10:38:32 +0800
Subject: [PATCH 011/506] fix(cli): keep prompt stable and flush stale
arrow-key input
---
nanobot/cli/commands.py | 40 ++++++++++++++++++++++++++++++++-
tests/test_cli_input_minimal.py | 37 ++++++++++++++++++++++++++++++
2 files changed, 76 insertions(+), 1 deletion(-)
create mode 100644 tests/test_cli_input_minimal.py
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 19e62e9..e70fd32 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -1,7 +1,10 @@
"""CLI commands for nanobot."""
import asyncio
+import os
from pathlib import Path
+import select
+import sys
import typer
from rich.console import Console
@@ -18,6 +21,40 @@ app = typer.Typer(
console = Console()
+def _flush_pending_tty_input() -> None:
+ """Drop unread keypresses typed while the model was generating output."""
+ try:
+ fd = sys.stdin.fileno()
+ if not os.isatty(fd):
+ return
+ except Exception:
+ return
+
+ try:
+ import termios
+
+ termios.tcflush(fd, termios.TCIFLUSH)
+ return
+ except Exception:
+ pass
+
+ try:
+ while True:
+ ready, _, _ = select.select([fd], [], [], 0)
+ if not ready:
+ break
+ if not os.read(fd, 4096):
+ break
+ except Exception:
+ return
+
+
+def _read_interactive_input() -> str:
+ """Read user input with a stable prompt for terminal line editing."""
+ console.print("[bold blue]You:[/bold blue] ", end="")
+ return input()
+
+
def version_callback(value: bool):
if value:
console.print(f"{__logo__} nanobot v{__version__}")
@@ -318,7 +355,8 @@ def agent(
async def run_interactive():
while True:
try:
- user_input = console.input("[bold blue]You:[/bold blue] ")
+ _flush_pending_tty_input()
+ user_input = _read_interactive_input()
if not user_input.strip():
continue
diff --git a/tests/test_cli_input_minimal.py b/tests/test_cli_input_minimal.py
new file mode 100644
index 0000000..49d9d4f
--- /dev/null
+++ b/tests/test_cli_input_minimal.py
@@ -0,0 +1,37 @@
+import builtins
+
+import nanobot.cli.commands as commands
+
+
+def test_read_interactive_input_uses_plain_input(monkeypatch) -> None:
+ captured: dict[str, object] = {}
+
+ def fake_print(*args, **kwargs):
+ captured["printed"] = args
+ captured["print_kwargs"] = kwargs
+
+ def fake_input(prompt: str = "") -> str:
+ captured["prompt"] = prompt
+ return "hello"
+
+ monkeypatch.setattr(commands.console, "print", fake_print)
+ monkeypatch.setattr(builtins, "input", fake_input)
+
+ value = commands._read_interactive_input()
+
+ assert value == "hello"
+ assert captured["prompt"] == ""
+ assert captured["print_kwargs"] == {"end": ""}
+ assert captured["printed"] == ("[bold blue]You:[/bold blue] ",)
+
+
+def test_flush_pending_tty_input_skips_non_tty(monkeypatch) -> None:
+ class FakeStdin:
+ def fileno(self) -> int:
+ return 0
+
+ monkeypatch.setattr(commands.sys, "stdin", FakeStdin())
+ monkeypatch.setattr(commands.os, "isatty", lambda _fd: False)
+
+ commands._flush_pending_tty_input()
+
From 342ba2b87976cb9c282e8d6760fbfa8133509703 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?=
Date: Sun, 8 Feb 2026 11:10:03 +0800
Subject: [PATCH 012/506] fix(cli): stabilize wrapped CJK arrow navigation in
interactive input
---
nanobot/cli/commands.py | 255 +++++++++++++++++++++++++++++++-
pyproject.toml | 1 +
tests/test_cli_input_minimal.py | 41 +++--
3 files changed, 282 insertions(+), 15 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index e70fd32..bd7a408 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -1,10 +1,12 @@
"""CLI commands for nanobot."""
import asyncio
+import atexit
import os
from pathlib import Path
import select
import sys
+from typing import Any
import typer
from rich.console import Console
@@ -19,6 +21,12 @@ app = typer.Typer(
)
console = Console()
+_READLINE: Any | None = None
+_HISTORY_FILE: Path | None = None
+_HISTORY_HOOK_REGISTERED = False
+_USING_LIBEDIT = False
+_PROMPT_SESSION: Any | None = None
+_PROMPT_SESSION_LABEL: Any = None
def _flush_pending_tty_input() -> None:
@@ -49,10 +57,248 @@ def _flush_pending_tty_input() -> None:
return
+def _save_history() -> None:
+ if _READLINE is None or _HISTORY_FILE is None:
+ return
+ try:
+ _READLINE.write_history_file(str(_HISTORY_FILE))
+ except Exception:
+ return
+
+
+def _enable_line_editing() -> None:
+ """Best-effort enable readline/libedit line editing for arrow keys/history."""
+ global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT
+ global _PROMPT_SESSION, _PROMPT_SESSION_LABEL
+
+ history_file = Path.home() / ".nanobot" / "history" / "cli_history"
+ history_file.parent.mkdir(parents=True, exist_ok=True)
+ _HISTORY_FILE = history_file
+
+ # Preferred path: prompt_toolkit handles wrapped wide-char rendering better.
+ try:
+ from prompt_toolkit import PromptSession
+ from prompt_toolkit.formatted_text import ANSI
+ from prompt_toolkit.history import FileHistory
+ from prompt_toolkit.key_binding import KeyBindings
+
+ key_bindings = KeyBindings()
+
+ @key_bindings.add("enter")
+ def _accept_input(event) -> None:
+ _clear_visual_nav_state(event.current_buffer)
+ event.current_buffer.validate_and_handle()
+
+ @key_bindings.add("up")
+ def _handle_up(event) -> None:
+ count = event.arg if event.arg and event.arg > 0 else 1
+ moved = _move_buffer_cursor_visual_from_render(
+ buffer=event.current_buffer,
+ event=event,
+ delta=-1,
+ count=count,
+ )
+ if not moved:
+ event.current_buffer.history_backward(count=count)
+ _clear_visual_nav_state(event.current_buffer)
+
+ @key_bindings.add("down")
+ def _handle_down(event) -> None:
+ count = event.arg if event.arg and event.arg > 0 else 1
+ moved = _move_buffer_cursor_visual_from_render(
+ buffer=event.current_buffer,
+ event=event,
+ delta=1,
+ count=count,
+ )
+ if not moved:
+ event.current_buffer.history_forward(count=count)
+ _clear_visual_nav_state(event.current_buffer)
+
+ _PROMPT_SESSION = PromptSession(
+ history=FileHistory(str(history_file)),
+ multiline=True,
+ wrap_lines=True,
+ complete_while_typing=False,
+ key_bindings=key_bindings,
+ )
+ _PROMPT_SESSION.default_buffer.on_text_changed += (
+ lambda _event: _clear_visual_nav_state(_PROMPT_SESSION.default_buffer)
+ )
+ _PROMPT_SESSION_LABEL = ANSI("\x1b[1;34mYou:\x1b[0m ")
+ _READLINE = None
+ _USING_LIBEDIT = False
+ return
+ except Exception:
+ _PROMPT_SESSION = None
+ _PROMPT_SESSION_LABEL = None
+
+ try:
+ import readline
+ except Exception:
+ return
+
+ _READLINE = readline
+ _USING_LIBEDIT = "libedit" in (readline.__doc__ or "").lower()
+
+ try:
+ if _USING_LIBEDIT:
+ readline.parse_and_bind("bind ^I rl_complete")
+ else:
+ readline.parse_and_bind("tab: complete")
+ readline.parse_and_bind("set editing-mode emacs")
+ except Exception:
+ pass
+
+ try:
+ readline.read_history_file(str(history_file))
+ except Exception:
+ pass
+
+ if not _HISTORY_HOOK_REGISTERED:
+ atexit.register(_save_history)
+ _HISTORY_HOOK_REGISTERED = True
+
+
+def _prompt_text() -> str:
+ """Build a readline-friendly colored prompt."""
+ if _READLINE is None:
+ return "You: "
+ # libedit on macOS does not honor GNU readline non-printing markers.
+ if _USING_LIBEDIT:
+ return "\033[1;34mYou:\033[0m "
+ return "\001\033[1;34m\002You:\001\033[0m\002 "
+
+
def _read_interactive_input() -> str:
- """Read user input with a stable prompt for terminal line editing."""
- console.print("[bold blue]You:[/bold blue] ", end="")
- return input()
+ """Read user input with stable prompt rendering (sync fallback)."""
+ return input(_prompt_text())
+
+
+async def _read_interactive_input_async() -> str:
+ """Read user input safely inside the interactive asyncio loop."""
+ if _PROMPT_SESSION is not None:
+ try:
+ return await _PROMPT_SESSION.prompt_async(_PROMPT_SESSION_LABEL)
+ except EOFError as exc:
+ raise KeyboardInterrupt from exc
+ try:
+ return await asyncio.to_thread(_read_interactive_input)
+ except EOFError as exc:
+ raise KeyboardInterrupt from exc
+
+
+def _choose_visual_rowcol(
+ rowcol_to_yx: dict[tuple[int, int], tuple[int, int]],
+ current_rowcol: tuple[int, int],
+ delta: int,
+ preferred_x: int | None = None,
+) -> tuple[tuple[int, int] | None, int | None]:
+ """Choose next logical row/col by rendered screen coordinates."""
+ if delta not in (-1, 1):
+ return None, preferred_x
+
+ current_yx = rowcol_to_yx.get(current_rowcol)
+ if current_yx is None:
+ same_row = [
+ (rowcol, yx)
+ for rowcol, yx in rowcol_to_yx.items()
+ if rowcol[0] == current_rowcol[0]
+ ]
+ if not same_row:
+ return None, preferred_x
+ _, current_yx = min(same_row, key=lambda item: abs(item[0][1] - current_rowcol[1]))
+
+ target_x = current_yx[1] if preferred_x is None else preferred_x
+ target_y = current_yx[0] + delta
+ candidates = [(rowcol, yx) for rowcol, yx in rowcol_to_yx.items() if yx[0] == target_y]
+ if not candidates:
+ return None, preferred_x
+
+ best_rowcol, _ = min(
+ candidates,
+ key=lambda item: (abs(item[1][1] - target_x), item[1][1] < target_x, item[1][1]),
+ )
+ return best_rowcol, target_x
+
+
+def _clear_visual_nav_state(buffer: Any) -> None:
+ """Reset cached vertical-navigation anchor state."""
+ setattr(buffer, "_nanobot_visual_pref_x", None)
+ setattr(buffer, "_nanobot_visual_last_dir", None)
+ setattr(buffer, "_nanobot_visual_last_cursor", None)
+ setattr(buffer, "_nanobot_visual_last_text", None)
+
+
+def _can_reuse_visual_anchor(buffer: Any, delta: int) -> bool:
+ """Reuse anchor only for uninterrupted vertical navigation."""
+ return (
+ getattr(buffer, "_nanobot_visual_last_dir", None) == delta
+ and getattr(buffer, "_nanobot_visual_last_cursor", None) == buffer.cursor_position
+ and getattr(buffer, "_nanobot_visual_last_text", None) == buffer.text
+ )
+
+
+def _remember_visual_anchor(buffer: Any, delta: int) -> None:
+ """Remember current state as anchor baseline for repeated up/down."""
+ setattr(buffer, "_nanobot_visual_last_dir", delta)
+ setattr(buffer, "_nanobot_visual_last_cursor", buffer.cursor_position)
+ setattr(buffer, "_nanobot_visual_last_text", buffer.text)
+
+
+def _move_buffer_cursor_visual_from_render(
+ buffer: Any,
+ event: Any,
+ delta: int,
+ count: int,
+) -> bool:
+ """Move cursor across rendered screen rows (soft-wrap/CJK aware)."""
+ try:
+ window = event.app.layout.current_window
+ render_info = getattr(window, "render_info", None)
+ rowcol_to_yx = getattr(render_info, "_rowcol_to_yx", None)
+ if not isinstance(rowcol_to_yx, dict) or not rowcol_to_yx:
+ return False
+ except Exception:
+ return False
+
+ moved_any = False
+ preferred_x = (
+ getattr(buffer, "_nanobot_visual_pref_x", None)
+ if _can_reuse_visual_anchor(buffer, delta)
+ else None
+ )
+ steps = max(1, count)
+
+ for _ in range(steps):
+ doc = buffer.document
+ current_rowcol = (doc.cursor_position_row, doc.cursor_position_col)
+ next_rowcol, preferred_x = _choose_visual_rowcol(
+ rowcol_to_yx=rowcol_to_yx,
+ current_rowcol=current_rowcol,
+ delta=delta,
+ preferred_x=preferred_x,
+ )
+ if next_rowcol is None:
+ break
+
+ try:
+ new_position = doc.translate_row_col_to_index(*next_rowcol)
+ except Exception:
+ break
+ if new_position == buffer.cursor_position:
+ break
+
+ buffer.cursor_position = new_position
+ moved_any = True
+
+ if moved_any:
+ setattr(buffer, "_nanobot_visual_pref_x", preferred_x)
+ _remember_visual_anchor(buffer, delta)
+ else:
+ _clear_visual_nav_state(buffer)
+
+ return moved_any
def version_callback(value: bool):
@@ -350,13 +596,14 @@ def agent(
asyncio.run(run_once())
else:
# Interactive mode
+ _enable_line_editing()
console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n")
async def run_interactive():
while True:
try:
_flush_pending_tty_input()
- user_input = _read_interactive_input()
+ user_input = await _read_interactive_input_async()
if not user_input.strip():
continue
diff --git a/pyproject.toml b/pyproject.toml
index 4093474..b1bc3de 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,7 @@ dependencies = [
"python-telegram-bot[socks]>=21.0",
"lark-oapi>=1.0.0",
"socksio>=1.0.0",
+ "prompt-toolkit>=3.0.47",
]
[project.optional-dependencies]
diff --git a/tests/test_cli_input_minimal.py b/tests/test_cli_input_minimal.py
index 49d9d4f..4726ea3 100644
--- a/tests/test_cli_input_minimal.py
+++ b/tests/test_cli_input_minimal.py
@@ -4,25 +4,45 @@ import nanobot.cli.commands as commands
def test_read_interactive_input_uses_plain_input(monkeypatch) -> None:
- captured: dict[str, object] = {}
-
- def fake_print(*args, **kwargs):
- captured["printed"] = args
- captured["print_kwargs"] = kwargs
-
+ captured: dict[str, str] = {}
def fake_input(prompt: str = "") -> str:
captured["prompt"] = prompt
return "hello"
- monkeypatch.setattr(commands.console, "print", fake_print)
monkeypatch.setattr(builtins, "input", fake_input)
+ monkeypatch.setattr(commands, "_PROMPT_SESSION", None)
+ monkeypatch.setattr(commands, "_READLINE", None)
value = commands._read_interactive_input()
assert value == "hello"
- assert captured["prompt"] == ""
- assert captured["print_kwargs"] == {"end": ""}
- assert captured["printed"] == ("[bold blue]You:[/bold blue] ",)
+ assert captured["prompt"] == "You: "
+
+
+def test_read_interactive_input_prefers_prompt_session(monkeypatch) -> None:
+ captured: dict[str, object] = {}
+
+ class FakePromptSession:
+ async def prompt_async(self, label: object) -> str:
+ captured["label"] = label
+ return "hello"
+
+ monkeypatch.setattr(commands, "_PROMPT_SESSION", FakePromptSession())
+ monkeypatch.setattr(commands, "_PROMPT_SESSION_LABEL", "LBL")
+
+ value = __import__("asyncio").run(commands._read_interactive_input_async())
+
+ assert value == "hello"
+ assert captured["label"] == "LBL"
+
+
+def test_prompt_text_for_readline_modes(monkeypatch) -> None:
+ monkeypatch.setattr(commands, "_READLINE", object())
+ monkeypatch.setattr(commands, "_USING_LIBEDIT", True)
+ assert commands._prompt_text() == "\033[1;34mYou:\033[0m "
+
+ monkeypatch.setattr(commands, "_USING_LIBEDIT", False)
+ assert "\001" in commands._prompt_text()
def test_flush_pending_tty_input_skips_non_tty(monkeypatch) -> None:
@@ -34,4 +54,3 @@ def test_flush_pending_tty_input_skips_non_tty(monkeypatch) -> None:
monkeypatch.setattr(commands.os, "isatty", lambda _fd: False)
commands._flush_pending_tty_input()
-
From 240db894b43ddf521c83850be57d8025cbc27562 Mon Sep 17 00:00:00 2001
From: w0x7ce
Date: Sun, 8 Feb 2026 11:37:36 +0800
Subject: [PATCH 013/506] feat(channels): add DingTalk channel support and
documentation
---
README.md | 43 +++++++
nanobot/channels/dingtalk.py | 219 +++++++++++++++++++++++++++++++++++
nanobot/channels/manager.py | 11 ++
nanobot/config/schema.py | 9 ++
pyproject.toml | 1 +
5 files changed, 283 insertions(+)
create mode 100644 nanobot/channels/dingtalk.py
diff --git a/README.md b/README.md
index a1ea905..95a5625 100644
--- a/README.md
+++ b/README.md
@@ -336,6 +336,49 @@ nanobot gateway
+
+DingTalk (钉钉)
+
+Uses **Stream Mode** — no public IP required.
+
+```bash
+pip install nanobot-ai[dingtalk]
+```
+
+**1. Create a DingTalk bot**
+- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
+- Create a new app -> Add **Robot** capability
+- **Configuration**:
+ - Toggle **Stream Mode** ON
+- **Permissions**: Add necessary permissions for sending messages
+- Get **AppKey** (Client ID) and **AppSecret** (Client Secret) from "Credentials"
+- Publish the app
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "clientId": "YOUR_APP_KEY",
+ "clientSecret": "YOUR_APP_SECRET",
+ "allowFrom": []
+ }
+ }
+}
+```
+
+> `allowFrom`: Leave empty to allow all users, or add `["staffId"]` to restrict access.
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
## ⚙️ Configuration
Config file: `~/.nanobot/config.json`
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
new file mode 100644
index 0000000..897e5be
--- /dev/null
+++ b/nanobot/channels/dingtalk.py
@@ -0,0 +1,219 @@
+"""DingTalk/DingDing channel implementation using Stream Mode."""
+
+import asyncio
+import json
+import threading
+import time
+from typing import Any
+
+from loguru import logger
+import httpx
+
+from nanobot.bus.events import OutboundMessage, InboundMessage
+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
+
+
+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 content based on message type
+ content = ""
+ if chatbot_msg.text:
+ content = chatbot_msg.text.content.strip()
+ elif chatbot_msg.message_type == "text":
+ # Fallback manual extraction if object not populated
+ content = message.data.get("text", {}).get("content", "").strip()
+
+ if not content:
+ logger.warning(f"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(f"Received DingTalk message from {sender_name} ({sender_id}): {content}")
+
+ # Forward to Nanobot
+ # We use asyncio.create_task to avoid blocking the ACK return
+ asyncio.create_task(
+ self.channel._on_message(content, sender_id, sender_name)
+ )
+
+ return AckMessage.STATUS_OK, "OK"
+
+ except Exception as e:
+ logger.error(f"Error processing DingTalk message: {e}")
+ # Return OK to avoid retry loop from DingTalk server if it's a parsing error
+ 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 (since SDK is mainly for receiving).
+ """
+
+ name = "dingtalk"
+
+ def __init__(self, config: DingTalkConfig, bus: MessageBus):
+ super().__init__(config, bus)
+ self.config: DingTalkConfig = config
+ self._client: Any = None
+ self._loop: asyncio.AbstractEventLoop | None = None
+
+ # Access Token management for sending messages
+ self._access_token: str | None = None
+ self._token_expiry: float = 0
+
+ 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._loop = asyncio.get_running_loop()
+
+ logger.info(f"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)
+
+ # Register using the chatbot topic standard for bots
+ self._client.register_callback_handler(
+ ChatbotMessage.TOPIC,
+ handler
+ )
+
+ logger.info("DingTalk bot started with Stream Mode")
+
+ # The client.start() method is an async infinite loop that handles the websocket connection
+ await self._client.start()
+
+ except Exception as e:
+ logger.exception(f"Failed to start DingTalk channel: {e}")
+
+ async def stop(self) -> None:
+ """Stop the DingTalk bot."""
+ self._running = False
+ # SDK doesn't expose a clean stop method that cancels loop immediately without private access
+ pass
+
+ 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
+ }
+
+ try:
+ async with httpx.AsyncClient() as client:
+ resp = await client.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(f"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
+
+ # This endpoint is for sending to a single user in a bot 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
+ }
+
+ # Convert markdown code blocks for basic compatibility if needed,
+ # but DingTalk supports markdown loosely.
+
+ data = {
+ "robotCode": self.config.client_id,
+ "userIds": [msg.chat_id], # chat_id is the user's staffId/unionId
+ "msgKey": "sampleMarkdown", # Using markdown template
+ "msgParam": json.dumps({
+ "text": msg.content,
+ "title": "Nanobot Reply"
+ })
+ }
+
+ try:
+ async with httpx.AsyncClient() as client:
+ resp = await client.post(url, json=data, headers=headers)
+ # Check 200 OK but also API error codes if any
+ if resp.status_code != 200:
+ logger.error(f"DingTalk send failed: {resp.text}")
+ else:
+ logger.debug(f"DingTalk message sent to {msg.chat_id}")
+ except Exception as e:
+ logger.error(f"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)."""
+ try:
+ logger.info(f"DingTalk inbound: {content} from {sender_name}")
+
+ # Correct InboundMessage usage based on events.py definition
+ # @dataclass class InboundMessage:
+ # channel: str, sender_id: str, chat_id: str, content: str, ...
+ msg = InboundMessage(
+ channel=self.name,
+ sender_id=sender_id,
+ chat_id=sender_id, # For private stats, chat_id is sender_id
+ content=str(content),
+ metadata={
+ "sender_name": sender_name,
+ "platform": "dingtalk"
+ }
+ )
+ await self.bus.publish_inbound(msg)
+ except Exception as e:
+ logger.error(f"Error publishing DingTalk message: {e}")
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 846ea70..c7ab7c3 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -77,6 +77,17 @@ class ChannelManager:
logger.info("Feishu channel enabled")
except ImportError as e:
logger.warning(f"Feishu channel not available: {e}")
+
+ # DingTalk channel
+ 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(f"DingTalk channel not available: {e}")
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""Start a channel and log any exceptions."""
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 7724288..e46b5df 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -30,6 +30,14 @@ class FeishuConfig(BaseModel):
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
+class DingTalkConfig(BaseModel):
+ """DingTalk channel configuration using Stream mode."""
+ enabled: bool = False
+ client_id: str = "" # AppKey
+ client_secret: str = "" # AppSecret
+ allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids
+
+
class DiscordConfig(BaseModel):
"""Discord channel configuration."""
enabled: bool = False
@@ -45,6 +53,7 @@ class ChannelsConfig(BaseModel):
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
discord: DiscordConfig = Field(default_factory=DiscordConfig)
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
+ dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig)
class AgentDefaults(BaseModel):
diff --git a/pyproject.toml b/pyproject.toml
index 4093474..6fda084 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ dependencies = [
"readability-lxml>=0.8.0",
"rich>=13.0.0",
"croniter>=2.0.0",
+ "dingtalk-stream>=0.4.0",
"python-telegram-bot[socks]>=21.0",
"lark-oapi>=1.0.0",
"socksio>=1.0.0",
From 3b61ae4fff435a4dce9675ecd2bdabf9c097f414 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 04:29:51 +0000
Subject: [PATCH 014/506] fix: skip provider prefix rules for
vLLM/OpenRouter/AiHubMix endpoints
---
nanobot/providers/litellm_provider.py | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 7a52e7c..415100c 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -107,11 +107,12 @@ class LiteLLMProvider(LLMProvider):
(("moonshot", "kimi"), "moonshot", ("moonshot/", "openrouter/")),
(("gemini",), "gemini", ("gemini/",)),
]
- model_lower = model.lower()
- for keywords, prefix, skip in _prefix_rules:
- if any(kw in model_lower for kw in keywords) and not any(model.startswith(s) for s in skip):
- model = f"{prefix}/{model}"
- break
+ if not (self.is_vllm or self.is_openrouter or self.is_aihubmix):
+ model_lower = model.lower()
+ for keywords, prefix, skip in _prefix_rules:
+ if any(kw in model_lower for kw in keywords) and not any(model.startswith(s) for s in skip):
+ model = f"{prefix}/{model}"
+ break
# Gateway/endpoint-specific prefixes (detected by api_base/api_key, not model name)
if self.is_openrouter and not model.startswith("openrouter/"):
From f7f812a1774ebe20ba8e46a7e71f0ac5f1de37b5 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 05:06:41 +0000
Subject: [PATCH 015/506] feat: add /reset and /help commands for Telegram bot
---
README.md | 2 +-
nanobot/agent/loop.py | 3 +-
nanobot/channels/manager.py | 11 ++++-
nanobot/channels/telegram.py | 81 ++++++++++++++++++++++++++++++++----
nanobot/cli/commands.py | 5 ++-
5 files changed, 88 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index a1ea905..ff827be 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,422 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,423 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index b13113f..a65f3a5 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -45,6 +45,7 @@ class AgentLoop:
exec_config: "ExecToolConfig | None" = None,
cron_service: "CronService | None" = None,
restrict_to_workspace: bool = False,
+ session_manager: SessionManager | None = None,
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
@@ -59,7 +60,7 @@ class AgentLoop:
self.restrict_to_workspace = restrict_to_workspace
self.context = ContextBuilder(workspace)
- self.sessions = SessionManager(workspace)
+ self.sessions = session_manager or SessionManager(workspace)
self.tools = ToolRegistry()
self.subagents = SubagentManager(
provider=provider,
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 846ea70..efb7db0 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -1,7 +1,9 @@
"""Channel manager for coordinating chat channels."""
+from __future__ import annotations
+
import asyncio
-from typing import Any
+from typing import Any, TYPE_CHECKING
from loguru import logger
@@ -10,6 +12,9 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
+if TYPE_CHECKING:
+ from nanobot.session.manager import SessionManager
+
class ChannelManager:
"""
@@ -21,9 +26,10 @@ class ChannelManager:
- Route outbound messages
"""
- def __init__(self, config: Config, bus: MessageBus):
+ def __init__(self, config: Config, bus: MessageBus, session_manager: "SessionManager | None" = None):
self.config = config
self.bus = bus
+ self.session_manager = session_manager
self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None
@@ -40,6 +46,7 @@ class ChannelManager:
self.config.channels.telegram,
self.bus,
groq_api_key=self.config.providers.groq.api_key,
+ session_manager=self.session_manager,
)
logger.info("Telegram channel enabled")
except ImportError as e:
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index f2b6d1f..4f62557 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -1,17 +1,23 @@
"""Telegram channel implementation using python-telegram-bot."""
+from __future__ import annotations
+
import asyncio
import re
+from typing import TYPE_CHECKING
from loguru import logger
-from telegram import Update
-from telegram.ext import Application, MessageHandler, filters, ContextTypes
+from telegram import BotCommand, Update
+from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import TelegramConfig
+if TYPE_CHECKING:
+ from nanobot.session.manager import SessionManager
+
def _markdown_to_telegram_html(text: str) -> str:
"""
@@ -85,10 +91,24 @@ class TelegramChannel(BaseChannel):
name = "telegram"
- def __init__(self, config: TelegramConfig, bus: MessageBus, groq_api_key: str = ""):
+ # Commands registered with Telegram's command menu
+ BOT_COMMANDS = [
+ BotCommand("start", "Start the bot"),
+ BotCommand("reset", "Reset conversation history"),
+ BotCommand("help", "Show available commands"),
+ ]
+
+ def __init__(
+ self,
+ config: TelegramConfig,
+ bus: MessageBus,
+ groq_api_key: str = "",
+ session_manager: SessionManager | None = None,
+ ):
super().__init__(config, bus)
self.config: TelegramConfig = config
self.groq_api_key = groq_api_key
+ self.session_manager = session_manager
self._app: Application | None = None
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
@@ -106,6 +126,11 @@ class TelegramChannel(BaseChannel):
builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy)
self._app = builder.build()
+ # Add command handlers
+ self._app.add_handler(CommandHandler("start", self._on_start))
+ self._app.add_handler(CommandHandler("reset", self._on_reset))
+ self._app.add_handler(CommandHandler("help", self._on_help))
+
# Add message handler for text, photos, voice, documents
self._app.add_handler(
MessageHandler(
@@ -115,20 +140,22 @@ class TelegramChannel(BaseChannel):
)
)
- # Add /start command handler
- from telegram.ext import CommandHandler
- self._app.add_handler(CommandHandler("start", self._on_start))
-
logger.info("Starting Telegram bot (polling mode)...")
# Initialize and start polling
await self._app.initialize()
await self._app.start()
- # Get bot info
+ # Get bot info and register command menu
bot_info = await self._app.bot.get_me()
logger.info(f"Telegram bot @{bot_info.username} connected")
+ try:
+ await self._app.bot.set_my_commands(self.BOT_COMMANDS)
+ logger.debug("Telegram bot commands registered")
+ except Exception as e:
+ logger.warning(f"Failed to register bot commands: {e}")
+
# Start polling (this runs until stopped)
await self._app.updater.start_polling(
allowed_updates=["message"],
@@ -187,9 +214,45 @@ class TelegramChannel(BaseChannel):
user = update.effective_user
await update.message.reply_text(
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
- "Send me a message and I'll respond!"
+ "Send me a message and I'll respond!\n"
+ "Type /help to see available commands."
)
+ async def _on_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle /reset command — clear conversation history."""
+ if not update.message or not update.effective_user:
+ return
+
+ chat_id = str(update.message.chat_id)
+ session_key = f"{self.name}:{chat_id}"
+
+ if self.session_manager is None:
+ logger.warning("/reset called but session_manager is not available")
+ await update.message.reply_text("⚠️ Session management is not available.")
+ return
+
+ session = self.session_manager.get_or_create(session_key)
+ msg_count = len(session.messages)
+ session.clear()
+ self.session_manager.save(session)
+
+ logger.info(f"Session reset for {session_key} (cleared {msg_count} messages)")
+ await update.message.reply_text("🔄 Conversation history cleared. Let's start fresh!")
+
+ async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle /help command — show available commands."""
+ if not update.message:
+ return
+
+ help_text = (
+ "🐈 nanobot commands\n\n"
+ "/start — Start the bot\n"
+ "/reset — Reset conversation history\n"
+ "/help — Show this help message\n\n"
+ "Just send me a text message to chat!"
+ )
+ await update.message.reply_text(help_text, parse_mode="HTML")
+
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:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 19e62e9..bfb3b1d 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -179,6 +179,7 @@ def gateway(
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
+ from nanobot.session.manager import SessionManager
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
@@ -192,6 +193,7 @@ def gateway(
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
+ session_manager = SessionManager(config.workspace_path)
# Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
@@ -208,6 +210,7 @@ def gateway(
exec_config=config.tools.exec,
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
+ session_manager=session_manager,
)
# Set cron callback (needs agent)
@@ -242,7 +245,7 @@ def gateway(
)
# Create channel manager
- channels = ChannelManager(config, bus)
+ channels = ChannelManager(config, bus, session_manager=session_manager)
if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
From 42c2d83d70251a98233057ba0e55047a4cb112e7 Mon Sep 17 00:00:00 2001
From: pinhua33
Date: Sun, 8 Feb 2026 13:41:47 +0800
Subject: [PATCH 016/506] refactor: remove Codex OAuth implementation and
integrate oauth-cli-kit
---
nanobot/auth/__init__.py | 8 -
nanobot/auth/codex/__init__.py | 7 -
nanobot/auth/codex/constants.py | 24 --
nanobot/auth/codex/flow.py | 274 ---------------------
nanobot/auth/codex/models.py | 15 --
nanobot/auth/codex/pkce.py | 77 ------
nanobot/auth/codex/server.py | 115 ---------
nanobot/auth/codex/storage.py | 118 ---------
nanobot/cli/commands.py | 20 +-
nanobot/providers/openai_codex_provider.py | 2 +-
pyproject.toml | 1 +
11 files changed, 15 insertions(+), 646 deletions(-)
delete mode 100644 nanobot/auth/__init__.py
delete mode 100644 nanobot/auth/codex/__init__.py
delete mode 100644 nanobot/auth/codex/constants.py
delete mode 100644 nanobot/auth/codex/flow.py
delete mode 100644 nanobot/auth/codex/models.py
delete mode 100644 nanobot/auth/codex/pkce.py
delete mode 100644 nanobot/auth/codex/server.py
delete mode 100644 nanobot/auth/codex/storage.py
diff --git a/nanobot/auth/__init__.py b/nanobot/auth/__init__.py
deleted file mode 100644
index ecdc1dc..0000000
--- a/nanobot/auth/__init__.py
+++ /dev/null
@@ -1,8 +0,0 @@
-"""Authentication modules."""
-
-from nanobot.auth.codex import get_codex_token, login_codex_oauth_interactive
-
-__all__ = [
- "get_codex_token",
- "login_codex_oauth_interactive",
-]
diff --git a/nanobot/auth/codex/__init__.py b/nanobot/auth/codex/__init__.py
deleted file mode 100644
index 7a9a39b..0000000
--- a/nanobot/auth/codex/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""Codex OAuth module."""
-
-from nanobot.auth.codex.flow import get_codex_token, login_codex_oauth_interactive
-__all__ = [
- "get_codex_token",
- "login_codex_oauth_interactive",
-]
diff --git a/nanobot/auth/codex/constants.py b/nanobot/auth/codex/constants.py
deleted file mode 100644
index 7f20aad..0000000
--- a/nanobot/auth/codex/constants.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Codex OAuth constants."""
-
-CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
-AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
-TOKEN_URL = "https://auth.openai.com/oauth/token"
-REDIRECT_URI = "http://localhost:1455/auth/callback"
-SCOPE = "openid profile email offline_access"
-JWT_CLAIM_PATH = "https://api.openai.com/auth"
-
-DEFAULT_ORIGINATOR = "nanobot"
-TOKEN_FILENAME = "codex.json"
-SUCCESS_HTML = (
- ""
- ""
- ""
- ""
- ""
- "Authentication successful"
- ""
- ""
- "Authentication successful. Return to your terminal to continue.
"
- ""
- ""
-)
diff --git a/nanobot/auth/codex/flow.py b/nanobot/auth/codex/flow.py
deleted file mode 100644
index d05feb7..0000000
--- a/nanobot/auth/codex/flow.py
+++ /dev/null
@@ -1,274 +0,0 @@
-"""Codex OAuth login and token management."""
-
-from __future__ import annotations
-
-import asyncio
-import sys
-import threading
-import time
-import urllib.parse
-import webbrowser
-from typing import Callable
-
-import httpx
-
-from nanobot.auth.codex.constants import (
- AUTHORIZE_URL,
- CLIENT_ID,
- DEFAULT_ORIGINATOR,
- REDIRECT_URI,
- SCOPE,
- TOKEN_URL,
-)
-from nanobot.auth.codex.models import CodexToken
-from nanobot.auth.codex.pkce import (
- _create_state,
- _decode_account_id,
- _generate_pkce,
- _parse_authorization_input,
- _parse_token_payload,
-)
-from nanobot.auth.codex.server import _start_local_server
-from nanobot.auth.codex.storage import (
- _FileLock,
- _get_token_path,
- _load_token_file,
- _save_token_file,
- _try_import_codex_cli_token,
-)
-
-
-async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken:
- data = {
- "grant_type": "authorization_code",
- "client_id": CLIENT_ID,
- "code": code,
- "code_verifier": verifier,
- "redirect_uri": REDIRECT_URI,
- }
- async with httpx.AsyncClient(timeout=30.0) as client:
- response = await client.post(
- TOKEN_URL,
- data=data,
- headers={"Content-Type": "application/x-www-form-urlencoded"},
- )
- if response.status_code != 200:
- raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}")
-
- payload = response.json()
- access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields")
-
- account_id = _decode_account_id(access)
- return CodexToken(
- access=access,
- refresh=refresh,
- expires=int(time.time() * 1000 + expires_in * 1000),
- account_id=account_id,
- )
-
-
-def _refresh_token(refresh_token: str) -> CodexToken:
- data = {
- "grant_type": "refresh_token",
- "refresh_token": refresh_token,
- "client_id": CLIENT_ID,
- }
- with httpx.Client(timeout=30.0) as client:
- response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
- if response.status_code != 200:
- raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}")
-
- payload = response.json()
- access, refresh, expires_in = _parse_token_payload(payload, "Token refresh response missing fields")
-
- account_id = _decode_account_id(access)
- return CodexToken(
- access=access,
- refresh=refresh,
- expires=int(time.time() * 1000 + expires_in * 1000),
- account_id=account_id,
- )
-
-
-def get_codex_token() -> CodexToken:
- """Get an available token (refresh if needed)."""
- token = _load_token_file() or _try_import_codex_cli_token()
- if not token:
- raise RuntimeError("Codex OAuth credentials not found. Please run the login command.")
-
- # Refresh 60 seconds early.
- now_ms = int(time.time() * 1000)
- if token.expires - now_ms > 60 * 1000:
- return token
-
- lock_path = _get_token_path().with_suffix(".lock")
- with _FileLock(lock_path):
- # Re-read to avoid stale token if another process refreshed it.
- token = _load_token_file() or token
- now_ms = int(time.time() * 1000)
- if token.expires - now_ms > 60 * 1000:
- return token
- try:
- refreshed = _refresh_token(token.refresh)
- _save_token_file(refreshed)
- return refreshed
- except Exception:
- # If refresh fails, re-read the file to avoid false negatives.
- latest = _load_token_file()
- if latest and latest.expires - now_ms > 0:
- return latest
- raise
-
-
-async def _read_stdin_line() -> str:
- loop = asyncio.get_running_loop()
- if hasattr(loop, "add_reader") and sys.stdin:
- future: asyncio.Future[str] = loop.create_future()
-
- def _on_readable() -> None:
- line = sys.stdin.readline()
- if not future.done():
- future.set_result(line)
-
- try:
- loop.add_reader(sys.stdin, _on_readable)
- except Exception:
- return await loop.run_in_executor(None, sys.stdin.readline)
-
- try:
- return await future
- finally:
- try:
- loop.remove_reader(sys.stdin)
- except Exception:
- pass
-
- return await loop.run_in_executor(None, sys.stdin.readline)
-
-
-async def _await_manual_input(print_fn: Callable[[str], None]) -> str:
- print_fn("[cyan]Paste the authorization code (or full redirect URL), or wait for the browser callback:[/cyan]")
- return await _read_stdin_line()
-
-
-def login_codex_oauth_interactive(
- print_fn: Callable[[str], None],
- prompt_fn: Callable[[str], str],
- originator: str = DEFAULT_ORIGINATOR,
-) -> CodexToken:
- """Interactive login flow."""
-
- async def _login_async() -> CodexToken:
- verifier, challenge = _generate_pkce()
- state = _create_state()
-
- params = {
- "response_type": "code",
- "client_id": CLIENT_ID,
- "redirect_uri": REDIRECT_URI,
- "scope": SCOPE,
- "code_challenge": challenge,
- "code_challenge_method": "S256",
- "state": state,
- "id_token_add_organizations": "true",
- "codex_cli_simplified_flow": "true",
- "originator": originator,
- }
- url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
-
- loop = asyncio.get_running_loop()
- code_future: asyncio.Future[str] = loop.create_future()
-
- def _notify(code_value: str) -> None:
- if code_future.done():
- return
- loop.call_soon_threadsafe(code_future.set_result, code_value)
-
- server, server_error = _start_local_server(state, on_code=_notify)
- print_fn("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]")
- print_fn(url)
- try:
- webbrowser.open(url)
- except Exception:
- pass
-
- if not server and server_error:
- print_fn(
- "[yellow]"
- f"Local callback server could not start ({server_error}). "
- "You will need to paste the callback URL or authorization code."
- "[/yellow]"
- )
-
- code: str | None = None
- try:
- if server:
- print_fn("[dim]Waiting for browser callback...[/dim]")
-
- tasks: list[asyncio.Task[object]] = []
- callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120))
- tasks.append(callback_task)
- manual_task = asyncio.create_task(_await_manual_input(print_fn))
- tasks.append(manual_task)
-
- done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
- for task in pending:
- task.cancel()
-
- for task in done:
- try:
- result = task.result()
- except asyncio.TimeoutError:
- result = None
- if not result:
- continue
- if task is manual_task:
- parsed_code, parsed_state = _parse_authorization_input(result)
- if parsed_state and parsed_state != state:
- raise RuntimeError("State validation failed.")
- code = parsed_code
- else:
- code = result
- if code:
- break
-
- if not code:
- prompt = "Please paste the callback URL or authorization code:"
- raw = await loop.run_in_executor(None, prompt_fn, prompt)
- parsed_code, parsed_state = _parse_authorization_input(raw)
- if parsed_state and parsed_state != state:
- raise RuntimeError("State validation failed.")
- code = parsed_code
-
- if not code:
- raise RuntimeError("Authorization code not found.")
-
- print_fn("[dim]Exchanging authorization code for tokens...[/dim]")
- token = await _exchange_code_for_token_async(code, verifier)
- _save_token_file(token)
- return token
- finally:
- if server:
- server.shutdown()
- server.server_close()
-
- try:
- asyncio.get_running_loop()
- except RuntimeError:
- return asyncio.run(_login_async())
-
- result: list[CodexToken] = []
- error: list[Exception] = []
-
- def _runner() -> None:
- try:
- result.append(asyncio.run(_login_async()))
- except Exception as exc:
- error.append(exc)
-
- thread = threading.Thread(target=_runner)
- thread.start()
- thread.join()
- if error:
- raise error[0]
- return result[0]
diff --git a/nanobot/auth/codex/models.py b/nanobot/auth/codex/models.py
deleted file mode 100644
index e3a5f55..0000000
--- a/nanobot/auth/codex/models.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""Codex OAuth data models."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-
-@dataclass
-class CodexToken:
- """Codex OAuth token data structure."""
-
- access: str
- refresh: str
- expires: int
- account_id: str
diff --git a/nanobot/auth/codex/pkce.py b/nanobot/auth/codex/pkce.py
deleted file mode 100644
index b682386..0000000
--- a/nanobot/auth/codex/pkce.py
+++ /dev/null
@@ -1,77 +0,0 @@
-"""PKCE and authorization helpers."""
-
-from __future__ import annotations
-
-import base64
-import hashlib
-import json
-import os
-import urllib.parse
-from typing import Any
-
-from nanobot.auth.codex.constants import JWT_CLAIM_PATH
-
-
-def _base64url(data: bytes) -> str:
- return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8")
-
-
-def _decode_base64url(data: str) -> bytes:
- padding = "=" * (-len(data) % 4)
- return base64.urlsafe_b64decode(data + padding)
-
-
-def _generate_pkce() -> tuple[str, str]:
- verifier = _base64url(os.urandom(32))
- challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest())
- return verifier, challenge
-
-
-def _create_state() -> str:
- return _base64url(os.urandom(16))
-
-
-def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]:
- value = raw.strip()
- if not value:
- return None, None
- try:
- url = urllib.parse.urlparse(value)
- qs = urllib.parse.parse_qs(url.query)
- code = qs.get("code", [None])[0]
- state = qs.get("state", [None])[0]
- if code:
- return code, state
- except Exception:
- pass
-
- if "#" in value:
- parts = value.split("#", 1)
- return parts[0] or None, parts[1] or None
-
- if "code=" in value:
- qs = urllib.parse.parse_qs(value)
- return qs.get("code", [None])[0], qs.get("state", [None])[0]
-
- return value, None
-
-
-def _decode_account_id(access_token: str) -> str:
- parts = access_token.split(".")
- if len(parts) != 3:
- raise ValueError("Invalid JWT token")
- payload = json.loads(_decode_base64url(parts[1]).decode("utf-8"))
- auth = payload.get(JWT_CLAIM_PATH) or {}
- account_id = auth.get("chatgpt_account_id")
- if not account_id:
- raise ValueError("Failed to extract account_id from token")
- return str(account_id)
-
-
-def _parse_token_payload(payload: dict[str, Any], missing_message: str) -> tuple[str, str, int]:
- access = payload.get("access_token")
- refresh = payload.get("refresh_token")
- expires_in = payload.get("expires_in")
- if not access or not refresh or not isinstance(expires_in, int):
- raise RuntimeError(missing_message)
- return access, refresh, expires_in
diff --git a/nanobot/auth/codex/server.py b/nanobot/auth/codex/server.py
deleted file mode 100644
index f31db19..0000000
--- a/nanobot/auth/codex/server.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""Local OAuth callback server."""
-
-from __future__ import annotations
-
-import socket
-import threading
-import urllib.parse
-from http.server import BaseHTTPRequestHandler, HTTPServer
-from typing import Any, Callable
-
-from nanobot.auth.codex.constants import SUCCESS_HTML
-
-
-class _OAuthHandler(BaseHTTPRequestHandler):
- """Local callback HTTP handler."""
-
- server_version = "NanobotOAuth/1.0"
- protocol_version = "HTTP/1.1"
-
- def do_GET(self) -> None: # noqa: N802
- try:
- url = urllib.parse.urlparse(self.path)
- if url.path != "/auth/callback":
- self.send_response(404)
- self.end_headers()
- self.wfile.write(b"Not found")
- return
-
- qs = urllib.parse.parse_qs(url.query)
- code = qs.get("code", [None])[0]
- state = qs.get("state", [None])[0]
-
- if state != self.server.expected_state:
- self.send_response(400)
- self.end_headers()
- self.wfile.write(b"State mismatch")
- return
-
- if not code:
- self.send_response(400)
- self.end_headers()
- self.wfile.write(b"Missing code")
- return
-
- self.server.code = code
- try:
- if getattr(self.server, "on_code", None):
- self.server.on_code(code)
- except Exception:
- pass
- body = SUCCESS_HTML.encode("utf-8")
- self.send_response(200)
- self.send_header("Content-Type", "text/html; charset=utf-8")
- self.send_header("Content-Length", str(len(body)))
- self.send_header("Connection", "close")
- self.end_headers()
- self.wfile.write(body)
- try:
- self.wfile.flush()
- except Exception:
- pass
- self.close_connection = True
- except Exception:
- self.send_response(500)
- self.end_headers()
- self.wfile.write(b"Internal error")
-
- def log_message(self, format: str, *args: Any) -> None: # noqa: A003
- # Suppress default logs to avoid noisy output.
- return
-
-
-class _OAuthServer(HTTPServer):
- """OAuth callback server with state."""
-
- def __init__(
- self,
- server_address: tuple[str, int],
- expected_state: str,
- on_code: Callable[[str], None] | None = None,
- ):
- super().__init__(server_address, _OAuthHandler)
- self.expected_state = expected_state
- self.code: str | None = None
- self.on_code = on_code
-
-
-def _start_local_server(
- state: str,
- on_code: Callable[[str], None] | None = None,
-) -> tuple[_OAuthServer | None, str | None]:
- """Start a local OAuth callback server on the first available localhost address."""
- try:
- addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM)
- except OSError as exc:
- return None, f"Failed to resolve localhost: {exc}"
-
- last_error: OSError | None = None
- for family, _socktype, _proto, _canonname, sockaddr in addrinfos:
- try:
- # Support IPv4/IPv6 to avoid missing callbacks when localhost resolves to ::1.
- class _AddrOAuthServer(_OAuthServer):
- address_family = family
-
- server = _AddrOAuthServer(sockaddr, state, on_code=on_code)
- thread = threading.Thread(target=server.serve_forever, daemon=True)
- thread.start()
- return server, None
- except OSError as exc:
- last_error = exc
- continue
-
- if last_error:
- return None, f"Local callback server failed to start: {last_error}"
- return None, "Local callback server failed to start: unknown error"
diff --git a/nanobot/auth/codex/storage.py b/nanobot/auth/codex/storage.py
deleted file mode 100644
index 31e5e3d..0000000
--- a/nanobot/auth/codex/storage.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""Token storage helpers."""
-
-from __future__ import annotations
-
-import json
-import os
-import time
-from pathlib import Path
-
-from nanobot.auth.codex.constants import TOKEN_FILENAME
-from nanobot.auth.codex.models import CodexToken
-from nanobot.utils.helpers import ensure_dir, get_data_path
-
-
-def _get_token_path() -> Path:
- auth_dir = ensure_dir(get_data_path() / "auth")
- return auth_dir / TOKEN_FILENAME
-
-
-def _load_token_file() -> CodexToken | None:
- path = _get_token_path()
- if not path.exists():
- return None
- try:
- data = json.loads(path.read_text(encoding="utf-8"))
- return CodexToken(
- access=data["access"],
- refresh=data["refresh"],
- expires=int(data["expires"]),
- account_id=data["account_id"],
- )
- except Exception:
- return None
-
-
-def _save_token_file(token: CodexToken) -> None:
- path = _get_token_path()
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(
- json.dumps(
- {
- "access": token.access,
- "refresh": token.refresh,
- "expires": token.expires,
- "account_id": token.account_id,
- },
- ensure_ascii=True,
- indent=2,
- ),
- encoding="utf-8",
- )
- try:
- os.chmod(path, 0o600)
- except Exception:
- # Ignore permission setting failures.
- pass
-
-
-def _try_import_codex_cli_token() -> CodexToken | None:
- codex_path = Path.home() / ".codex" / "auth.json"
- if not codex_path.exists():
- return None
- try:
- data = json.loads(codex_path.read_text(encoding="utf-8"))
- tokens = data.get("tokens") or {}
- access = tokens.get("access_token")
- refresh = tokens.get("refresh_token")
- account_id = tokens.get("account_id")
- if not access or not refresh or not account_id:
- return None
- try:
- mtime = codex_path.stat().st_mtime
- expires = int(mtime * 1000 + 60 * 60 * 1000)
- except Exception:
- expires = int(time.time() * 1000 + 60 * 60 * 1000)
- token = CodexToken(
- access=str(access),
- refresh=str(refresh),
- expires=expires,
- account_id=str(account_id),
- )
- _save_token_file(token)
- return token
- except Exception:
- return None
-
-
-class _FileLock:
- """Simple file lock to reduce concurrent refreshes."""
-
- def __init__(self, path: Path):
- self._path = path
- self._fp = None
-
- def __enter__(self) -> "_FileLock":
- self._path.parent.mkdir(parents=True, exist_ok=True)
- self._fp = open(self._path, "a+")
- try:
- import fcntl
-
- fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
- except Exception:
- # Non-POSIX or failed lock: continue without locking.
- pass
- return self
-
- def __exit__(self, exc_type, exc, tb) -> None:
- try:
- import fcntl
-
- fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
- except Exception:
- pass
- try:
- if self._fp:
- self._fp.close()
- except Exception:
- pass
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 3be4e23..727c451 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -1,6 +1,7 @@
"""CLI commands for nanobot."""
import asyncio
+import sys
from pathlib import Path
import typer
@@ -18,6 +19,12 @@ app = typer.Typer(
console = Console()
+def _safe_print(text: str) -> None:
+ encoding = sys.stdout.encoding or "utf-8"
+ safe_text = text.encode(encoding, errors="replace").decode(encoding, errors="replace")
+ console.print(safe_text)
+
+
def version_callback(value: bool):
if value:
console.print(f"{__logo__} nanobot v{__version__}")
@@ -82,7 +89,7 @@ def login(
console.print(f"[red]Unsupported provider: {provider}[/red]")
raise typer.Exit(1)
- from nanobot.auth.codex import login_codex_oauth_interactive
+ from oauth_cli_kit import login_oauth_interactive as login_codex_oauth_interactive
console.print("[green]Starting OpenAI Codex OAuth login...[/green]")
login_codex_oauth_interactive(
@@ -180,7 +187,7 @@ def gateway(
from nanobot.bus.queue import MessageBus
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
- from nanobot.auth.codex import get_codex_token
+ from oauth_cli_kit import get_token as get_codex_token
from nanobot.agent.loop import AgentLoop
from nanobot.channels.manager import ChannelManager
from nanobot.cron.service import CronService
@@ -316,7 +323,7 @@ def agent(
from nanobot.bus.queue import MessageBus
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
- from nanobot.auth.codex import get_codex_token
+ from oauth_cli_kit import get_token as get_codex_token
from nanobot.agent.loop import AgentLoop
config = load_config()
@@ -361,7 +368,7 @@ def agent(
# Single message mode
async def run_once():
response = await agent_loop.process_direct(message, session_id)
- console.print(f"\n{__logo__} {response}")
+ _safe_print(f"\n{__logo__} {response}")
asyncio.run(run_once())
else:
@@ -376,7 +383,7 @@ def agent(
continue
response = await agent_loop.process_direct(user_input, session_id)
- console.print(f"\n{__logo__} {response}\n")
+ _safe_print(f"\n{__logo__} {response}\n")
except KeyboardInterrupt:
console.print("\nGoodbye!")
break
@@ -667,7 +674,7 @@ def cron_run(
def status():
"""Show nanobot status."""
from nanobot.config.loader import load_config, get_config_path
- from nanobot.auth.codex import get_codex_token
+ from oauth_cli_kit import get_token as get_codex_token
config_path = get_config_path()
config = load_config()
@@ -704,4 +711,3 @@ def status():
if __name__ == "__main__":
app()
-
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index a23becd..f92db09 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -9,7 +9,7 @@ from typing import Any, AsyncGenerator
import httpx
-from nanobot.auth.codex import get_codex_token
+from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
diff --git a/pyproject.toml b/pyproject.toml
index 0c59f66..a1931e5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,7 @@ dependencies = [
"websockets>=12.0",
"websocket-client>=1.6.0",
"httpx>=0.25.0",
+ "oauth-cli-kit>=0.1.1",
"loguru>=0.7.0",
"readability-lxml>=0.8.0",
"rich>=13.0.0",
From 00185f2beea1fbab70a2aa9e229d35a7aa54d6fa Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 05:44:06 +0000
Subject: [PATCH 017/506] feat: add Telegram typing indicator
---
.gitignore | 1 +
nanobot/channels/telegram.py | 38 +++++++++++++++++++++++++++++++++++-
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 316e214..55338f7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,4 @@ docs/
__pycache__/
poetry.lock
.pytest_cache/
+tests/
\ No newline at end of file
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 4f62557..ff46c86 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -111,6 +111,7 @@ class TelegramChannel(BaseChannel):
self.session_manager = session_manager
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."""
@@ -170,6 +171,10 @@ class TelegramChannel(BaseChannel):
"""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()
@@ -183,6 +188,9 @@ class TelegramChannel(BaseChannel):
logger.warning("Telegram bot not running")
return
+ # Stop typing indicator for this chat
+ self._stop_typing(msg.chat_id)
+
try:
# chat_id should be the Telegram chat ID (integer)
chat_id = int(msg.chat_id)
@@ -335,10 +343,15 @@ class TelegramChannel(BaseChannel):
logger.debug(f"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),
+ chat_id=str_chat_id,
content=content,
media=media_paths,
metadata={
@@ -350,6 +363,29 @@ class TelegramChannel(BaseChannel):
}
)
+ 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(f"Typing indicator stopped for {chat_id}: {e}")
+
def _get_extension(self, media_type: str, mime_type: str | None) -> str:
"""Get file extension based on media type."""
if mime_type:
From 299d8b33b31418bd6e4f0b38260a937f8789dca4 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 07:29:31 +0000
Subject: [PATCH 018/506] refactor: replace provider if-elif chains with
declarative registry
---
README.md | 48 ++++
nanobot/cli/commands.py | 33 ++-
nanobot/config/schema.py | 47 ++--
nanobot/providers/litellm_provider.py | 133 ++++++-----
nanobot/providers/registry.py | 323 ++++++++++++++++++++++++++
5 files changed, 474 insertions(+), 110 deletions(-)
create mode 100644 nanobot/providers/registry.py
diff --git a/README.md b/README.md
index ff827be..90ca9e3 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
## 📢 News
+- **2026-02-08** 🔧 Refactored Providers — adding a new LLM provider only takes just 2 steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
@@ -355,6 +356,53 @@ Config file: `~/.nanobot/config.json`
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
+| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
+| `vllm` | LLM (local, any OpenAI-compatible server) | — |
+
+
+Adding a New Provider (Developer Guide)
+
+nanobot uses a **Provider Registry** (`nanobot/providers/registry.py`) as the single source of truth.
+Adding a new provider only takes **2 steps** — no if-elif chains to touch.
+
+**Step 1.** Add a `ProviderSpec` entry to `PROVIDERS` in `nanobot/providers/registry.py`:
+
+```python
+ProviderSpec(
+ name="myprovider", # config field name
+ keywords=("myprovider", "mymodel"), # model-name keywords for auto-matching
+ env_key="MYPROVIDER_API_KEY", # env var for LiteLLM
+ display_name="My Provider", # shown in `nanobot status`
+ litellm_prefix="myprovider", # auto-prefix: model → myprovider/model
+ skip_prefixes=("myprovider/",), # don't double-prefix
+)
+```
+
+**Step 2.** Add a field to `ProvidersConfig` in `nanobot/config/schema.py`:
+
+```python
+class ProvidersConfig(BaseModel):
+ ...
+ myprovider: ProviderConfig = ProviderConfig()
+```
+
+That's it! Environment variables, model prefixing, config matching, and `nanobot status` display will all work automatically.
+
+**Common `ProviderSpec` options:**
+
+| Field | Description | Example |
+|-------|-------------|---------|
+| `litellm_prefix` | Auto-prefix model names for LiteLLM | `"dashscope"` → `dashscope/qwen-max` |
+| `skip_prefixes` | Don't prefix if model already starts with these | `("dashscope/", "openrouter/")` |
+| `env_extras` | Additional env vars to set | `(("ZHIPUAI_API_KEY", "{api_key}"),)` |
+| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}),)` |
+| `is_gateway` | Can route any model (like OpenRouter) | `True` |
+| `detect_by_key_prefix` | Detect gateway by API key prefix | `"sk-or-"` |
+| `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` |
+| `strip_model_prefix` | Strip existing prefix before re-prefixing | `True` (for AiHubMix) |
+
+
### Security
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index bfb3b1d..1dab818 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -635,25 +635,24 @@ def status():
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}")
- # Check API keys
- has_openrouter = bool(config.providers.openrouter.api_key)
- has_anthropic = bool(config.providers.anthropic.api_key)
- has_openai = bool(config.providers.openai.api_key)
- has_gemini = bool(config.providers.gemini.api_key)
- has_zhipu = bool(config.providers.zhipu.api_key)
- has_vllm = bool(config.providers.vllm.api_base)
- has_aihubmix = bool(config.providers.aihubmix.api_key)
-
- console.print(f"OpenRouter API: {'[green]✓[/green]' if has_openrouter else '[dim]not set[/dim]'}")
- console.print(f"Anthropic API: {'[green]✓[/green]' if has_anthropic else '[dim]not set[/dim]'}")
- console.print(f"OpenAI API: {'[green]✓[/green]' if has_openai else '[dim]not set[/dim]'}")
- console.print(f"Gemini API: {'[green]✓[/green]' if has_gemini else '[dim]not set[/dim]'}")
- console.print(f"Zhipu AI API: {'[green]✓[/green]' if has_zhipu else '[dim]not set[/dim]'}")
- console.print(f"AiHubMix API: {'[green]✓[/green]' if has_aihubmix else '[dim]not set[/dim]'}")
- vllm_status = f"[green]✓ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]"
- console.print(f"vLLM/Local: {vllm_status}")
+ # Check API keys from registry
+ for spec in PROVIDERS:
+ p = getattr(config.providers, spec.name, None)
+ if p is None:
+ continue
+ if 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]'}")
if __name__ == "__main__":
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 7724288..ea8f8ba 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -125,29 +125,23 @@ class Config(BaseSettings):
"""Get expanded workspace path."""
return Path(self.agents.defaults.workspace).expanduser()
- # Default base URLs for API gateways
- _GATEWAY_DEFAULTS = {"openrouter": "https://openrouter.ai/api/v1", "aihubmix": "https://aihubmix.com/v1"}
-
def get_provider(self, model: str | None = None) -> ProviderConfig | None:
"""Get matched provider config (api_key, api_base, extra_headers). Falls back to first available."""
- model = (model or self.agents.defaults.model).lower()
- p = self.providers
- # Keyword → provider mapping (order matters: gateways first)
- keyword_map = {
- "aihubmix": p.aihubmix, "openrouter": p.openrouter,
- "deepseek": p.deepseek, "anthropic": p.anthropic, "claude": p.anthropic,
- "openai": p.openai, "gpt": p.openai, "gemini": p.gemini,
- "zhipu": p.zhipu, "glm": p.zhipu, "zai": p.zhipu,
- "dashscope": p.dashscope, "qwen": p.dashscope,
- "groq": p.groq, "moonshot": p.moonshot, "kimi": p.moonshot, "vllm": p.vllm,
- }
- for kw, provider in keyword_map.items():
- if kw in model and provider.api_key:
- return provider
- # Fallback: gateways first (can serve any model), then specific providers
- all_providers = [p.openrouter, p.aihubmix, p.anthropic, p.openai, p.deepseek,
- p.gemini, p.zhipu, p.dashscope, p.moonshot, p.vllm, p.groq]
- return next((pr for pr in all_providers if pr.api_key), None)
+ from nanobot.providers.registry import PROVIDERS
+ model_lower = (model or self.agents.defaults.model).lower()
+
+ # Match by keyword (order follows PROVIDERS registry)
+ for spec in PROVIDERS:
+ p = getattr(self.providers, spec.name, None)
+ if p and any(kw in model_lower for kw in spec.keywords) and p.api_key:
+ return p
+
+ # Fallback: gateways first, then others (follows registry order)
+ for spec in PROVIDERS:
+ p = getattr(self.providers, spec.name, None)
+ if p and p.api_key:
+ return p
+ return None
def get_api_key(self, model: str | None = None) -> str | None:
"""Get API key for the given model. Falls back to first available key."""
@@ -156,13 +150,16 @@ class Config(BaseSettings):
def get_api_base(self, model: str | None = None) -> str | None:
"""Get API base URL for the given model. Applies default URLs for known gateways."""
+ from nanobot.providers.registry import PROVIDERS
p = self.get_provider(model)
if p and p.api_base:
return p.api_base
- # Default URLs for known gateways (openrouter, aihubmix)
- for name, url in self._GATEWAY_DEFAULTS.items():
- if p == getattr(self.providers, name):
- return url
+ # Only gateways get a default URL here. Standard providers (like Moonshot)
+ # handle their base URL via env vars in _setup_env, NOT via api_base —
+ # otherwise find_gateway() would misdetect them as local/vLLM.
+ for spec in PROVIDERS:
+ if spec.is_gateway and spec.default_api_base and p == getattr(self.providers, spec.name, None):
+ return spec.default_api_base
return None
class Config:
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 415100c..5e9c22f 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -1,5 +1,6 @@
"""LiteLLM provider implementation for multi-provider support."""
+import json
import os
from typing import Any
@@ -7,6 +8,7 @@ import litellm
from litellm import acompletion
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
+from nanobot.providers.registry import find_by_model, find_gateway
class LiteLLMProvider(LLMProvider):
@@ -14,7 +16,8 @@ class LiteLLMProvider(LLMProvider):
LLM provider using LiteLLM for multi-provider support.
Supports OpenRouter, Anthropic, OpenAI, Gemini, and many other providers through
- a unified interface.
+ a unified interface. Provider-specific logic is driven by the registry
+ (see providers/registry.py) — no if-elif chains needed here.
"""
def __init__(
@@ -28,47 +31,17 @@ class LiteLLMProvider(LLMProvider):
self.default_model = default_model
self.extra_headers = extra_headers or {}
- # Detect OpenRouter by api_key prefix or explicit api_base
- self.is_openrouter = (
- (api_key and api_key.startswith("sk-or-")) or
- (api_base and "openrouter" in api_base)
- )
+ # Detect gateway / local deployment from api_key and api_base
+ self._gateway = find_gateway(api_key, api_base)
- # Detect AiHubMix by api_base
- self.is_aihubmix = bool(api_base and "aihubmix" in api_base)
+ # Backwards-compatible flags (used by tests and possibly external code)
+ self.is_openrouter = bool(self._gateway and self._gateway.name == "openrouter")
+ self.is_aihubmix = bool(self._gateway and self._gateway.name == "aihubmix")
+ self.is_vllm = bool(self._gateway and self._gateway.is_local)
- # Track if using custom endpoint (vLLM, etc.)
- self.is_vllm = bool(api_base) and not self.is_openrouter and not self.is_aihubmix
-
- # Configure LiteLLM based on provider
+ # Configure environment variables
if api_key:
- if self.is_openrouter:
- # OpenRouter mode - set key
- os.environ["OPENROUTER_API_KEY"] = api_key
- elif self.is_aihubmix:
- # AiHubMix gateway - OpenAI-compatible
- os.environ["OPENAI_API_KEY"] = api_key
- elif self.is_vllm:
- # vLLM/custom endpoint - uses OpenAI-compatible API
- os.environ["HOSTED_VLLM_API_KEY"] = api_key
- elif "deepseek" in default_model:
- os.environ.setdefault("DEEPSEEK_API_KEY", api_key)
- elif "anthropic" in default_model:
- os.environ.setdefault("ANTHROPIC_API_KEY", api_key)
- elif "openai" in default_model or "gpt" in default_model:
- os.environ.setdefault("OPENAI_API_KEY", api_key)
- elif "gemini" in default_model.lower():
- os.environ.setdefault("GEMINI_API_KEY", api_key)
- elif "zhipu" in default_model or "glm" in default_model or "zai" in default_model:
- os.environ.setdefault("ZAI_API_KEY", api_key)
- os.environ.setdefault("ZHIPUAI_API_KEY", api_key)
- elif "dashscope" in default_model or "qwen" in default_model.lower():
- os.environ.setdefault("DASHSCOPE_API_KEY", api_key)
- elif "groq" in default_model:
- os.environ.setdefault("GROQ_API_KEY", api_key)
- elif "moonshot" in default_model or "kimi" in default_model:
- os.environ.setdefault("MOONSHOT_API_KEY", api_key)
- os.environ.setdefault("MOONSHOT_API_BASE", api_base or "https://api.moonshot.cn/v1")
+ self._setup_env(api_key, api_base, default_model)
if api_base:
litellm.api_base = api_base
@@ -76,6 +49,55 @@ class LiteLLMProvider(LLMProvider):
# Disable LiteLLM logging noise
litellm.suppress_debug_info = True
+ def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None:
+ """Set environment variables based on detected provider."""
+ if self._gateway:
+ # Gateway / local: direct set (not setdefault)
+ os.environ[self._gateway.env_key] = api_key
+ return
+
+ # Standard provider: match by model name
+ spec = find_by_model(model)
+ if spec:
+ os.environ.setdefault(spec.env_key, api_key)
+ # Resolve env_extras placeholders:
+ # {api_key} → user's API key
+ # {api_base} → user's api_base, falling back to spec.default_api_base
+ effective_base = api_base or spec.default_api_base
+ for env_name, env_val in spec.env_extras:
+ resolved = env_val.replace("{api_key}", api_key)
+ resolved = resolved.replace("{api_base}", effective_base)
+ os.environ.setdefault(env_name, resolved)
+
+ def _resolve_model(self, model: str) -> str:
+ """Resolve model name by applying provider/gateway prefixes."""
+ if self._gateway:
+ # Gateway mode: apply gateway prefix, skip provider-specific prefixes
+ prefix = self._gateway.litellm_prefix
+ if self._gateway.strip_model_prefix:
+ model = model.split("/")[-1]
+ if prefix and not model.startswith(f"{prefix}/"):
+ model = f"{prefix}/{model}"
+ return model
+
+ # Standard mode: auto-prefix for known providers
+ spec = find_by_model(model)
+ if spec and spec.litellm_prefix:
+ if not any(model.startswith(s) for s in spec.skip_prefixes):
+ model = f"{spec.litellm_prefix}/{model}"
+
+ return model
+
+ def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
+ """Apply model-specific parameter overrides from the registry."""
+ model_lower = model.lower()
+ spec = find_by_model(model)
+ if spec:
+ for pattern, overrides in spec.model_overrides:
+ if pattern in model_lower:
+ kwargs.update(overrides)
+ return
+
async def chat(
self,
messages: list[dict[str, Any]],
@@ -97,35 +119,8 @@ class LiteLLMProvider(LLMProvider):
Returns:
LLMResponse with content and/or tool calls.
"""
- model = model or self.default_model
+ model = self._resolve_model(model or self.default_model)
- # Auto-prefix model names for known providers
- # (keywords, target_prefix, skip_if_starts_with)
- _prefix_rules = [
- (("glm", "zhipu"), "zai", ("zhipu/", "zai/", "openrouter/", "hosted_vllm/")),
- (("qwen", "dashscope"), "dashscope", ("dashscope/", "openrouter/")),
- (("moonshot", "kimi"), "moonshot", ("moonshot/", "openrouter/")),
- (("gemini",), "gemini", ("gemini/",)),
- ]
- if not (self.is_vllm or self.is_openrouter or self.is_aihubmix):
- model_lower = model.lower()
- for keywords, prefix, skip in _prefix_rules:
- if any(kw in model_lower for kw in keywords) and not any(model.startswith(s) for s in skip):
- model = f"{prefix}/{model}"
- break
-
- # Gateway/endpoint-specific prefixes (detected by api_base/api_key, not model name)
- if self.is_openrouter and not model.startswith("openrouter/"):
- model = f"openrouter/{model}"
- elif self.is_aihubmix:
- model = f"openai/{model.split('/')[-1]}"
- elif self.is_vllm:
- model = f"hosted_vllm/{model}"
-
- # kimi-k2.5 only supports temperature=1.0
- if "kimi-k2.5" in model.lower():
- temperature = 1.0
-
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
@@ -133,6 +128,9 @@ class LiteLLMProvider(LLMProvider):
"temperature": temperature,
}
+ # Apply model-specific overrides (e.g. kimi-k2.5 temperature)
+ self._apply_model_overrides(model, kwargs)
+
# Pass api_base directly for custom endpoints (vLLM, etc.)
if self.api_base:
kwargs["api_base"] = self.api_base
@@ -166,7 +164,6 @@ class LiteLLMProvider(LLMProvider):
# Parse arguments from JSON string if needed
args = tc.function.arguments
if isinstance(args, str):
- import json
try:
args = json.loads(args)
except json.JSONDecodeError:
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
new file mode 100644
index 0000000..aa4a76e
--- /dev/null
+++ b/nanobot/providers/registry.py
@@ -0,0 +1,323 @@
+"""
+Provider Registry — single source of truth for LLM provider metadata.
+
+Adding a new provider:
+ 1. Add a ProviderSpec to PROVIDERS below.
+ 2. Add a field to ProvidersConfig in config/schema.py.
+ Done. Env vars, prefixing, config matching, status display all derive from here.
+
+Order matters — it controls match priority and fallback. Gateways first.
+Every entry writes out all fields so you can copy-paste as a template.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class ProviderSpec:
+ """One LLM provider's metadata. See PROVIDERS below for real examples.
+
+ Placeholders in env_extras values:
+ {api_key} — the user's API key
+ {api_base} — api_base from config, or this spec's default_api_base
+ """
+
+ # identity
+ name: str # config field name, e.g. "dashscope"
+ keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
+ env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY"
+ display_name: str = "" # shown in `nanobot status`
+
+ # model prefixing
+ litellm_prefix: str = "" # "dashscope" → model becomes "dashscope/{model}"
+ skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these
+
+ # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),)
+ env_extras: tuple[tuple[str, str], ...] = ()
+
+ # gateway / local detection
+ is_gateway: bool = False # routes any model (OpenRouter, AiHubMix)
+ is_local: bool = False # local deployment (vLLM, Ollama)
+ detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-"
+ detect_by_base_keyword: str = "" # match substring in api_base URL
+ default_api_base: str = "" # fallback base URL
+
+ # gateway behavior
+ strip_model_prefix: bool = False # strip "provider/" before re-prefixing
+
+ # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),)
+ model_overrides: tuple[tuple[str, dict[str, Any]], ...] = ()
+
+ @property
+ def label(self) -> str:
+ return self.display_name or self.name.title()
+
+
+# ---------------------------------------------------------------------------
+# PROVIDERS — the registry. Order = priority. Copy any entry as template.
+# ---------------------------------------------------------------------------
+
+PROVIDERS: tuple[ProviderSpec, ...] = (
+
+ # === Gateways (detected by api_key / api_base, not model name) =========
+ # Gateways can route any model, so they win in fallback.
+
+ # OpenRouter: global gateway, keys start with "sk-or-"
+ ProviderSpec(
+ name="openrouter",
+ keywords=("openrouter",),
+ env_key="OPENROUTER_API_KEY",
+ display_name="OpenRouter",
+ litellm_prefix="openrouter", # claude-3 → openrouter/claude-3
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=True,
+ is_local=False,
+ detect_by_key_prefix="sk-or-",
+ detect_by_base_keyword="openrouter",
+ default_api_base="https://openrouter.ai/api/v1",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # AiHubMix: global gateway, OpenAI-compatible interface.
+ # strip_model_prefix=True: it doesn't understand "anthropic/claude-3",
+ # so we strip to bare "claude-3" then re-prefix as "openai/claude-3".
+ ProviderSpec(
+ name="aihubmix",
+ keywords=("aihubmix",),
+ env_key="OPENAI_API_KEY", # OpenAI-compatible
+ display_name="AiHubMix",
+ litellm_prefix="openai", # → openai/{model}
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=True,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="aihubmix",
+ default_api_base="https://aihubmix.com/v1",
+ strip_model_prefix=True, # anthropic/claude-3 → claude-3 → openai/claude-3
+ model_overrides=(),
+ ),
+
+ # === Standard providers (matched by model-name keywords) ===============
+
+ # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed.
+ ProviderSpec(
+ name="anthropic",
+ keywords=("anthropic", "claude"),
+ env_key="ANTHROPIC_API_KEY",
+ display_name="Anthropic",
+ litellm_prefix="",
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed.
+ ProviderSpec(
+ name="openai",
+ keywords=("openai", "gpt"),
+ env_key="OPENAI_API_KEY",
+ display_name="OpenAI",
+ litellm_prefix="",
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
+ ProviderSpec(
+ name="deepseek",
+ keywords=("deepseek",),
+ env_key="DEEPSEEK_API_KEY",
+ display_name="DeepSeek",
+ litellm_prefix="deepseek", # deepseek-chat → deepseek/deepseek-chat
+ skip_prefixes=("deepseek/",), # avoid double-prefix
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # Gemini: needs "gemini/" prefix for LiteLLM.
+ ProviderSpec(
+ name="gemini",
+ keywords=("gemini",),
+ env_key="GEMINI_API_KEY",
+ display_name="Gemini",
+ litellm_prefix="gemini", # gemini-pro → gemini/gemini-pro
+ skip_prefixes=("gemini/",), # avoid double-prefix
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # Zhipu: LiteLLM uses "zai/" prefix.
+ # Also mirrors key to ZHIPUAI_API_KEY (some LiteLLM paths check that).
+ # skip_prefixes: don't add "zai/" when already routed via gateway.
+ ProviderSpec(
+ name="zhipu",
+ keywords=("zhipu", "glm", "zai"),
+ env_key="ZAI_API_KEY",
+ display_name="Zhipu AI",
+ litellm_prefix="zai", # glm-4 → zai/glm-4
+ skip_prefixes=("zhipu/", "zai/", "openrouter/", "hosted_vllm/"),
+ env_extras=(
+ ("ZHIPUAI_API_KEY", "{api_key}"),
+ ),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # DashScope: Qwen models, needs "dashscope/" prefix.
+ ProviderSpec(
+ name="dashscope",
+ keywords=("qwen", "dashscope"),
+ env_key="DASHSCOPE_API_KEY",
+ display_name="DashScope",
+ litellm_prefix="dashscope", # qwen-max → dashscope/qwen-max
+ skip_prefixes=("dashscope/", "openrouter/"),
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # Moonshot: Kimi models, needs "moonshot/" prefix.
+ # LiteLLM requires MOONSHOT_API_BASE env var to find the endpoint.
+ # Kimi K2.5 API enforces temperature >= 1.0.
+ ProviderSpec(
+ name="moonshot",
+ keywords=("moonshot", "kimi"),
+ env_key="MOONSHOT_API_KEY",
+ display_name="Moonshot",
+ litellm_prefix="moonshot", # kimi-k2.5 → moonshot/kimi-k2.5
+ skip_prefixes=("moonshot/", "openrouter/"),
+ env_extras=(
+ ("MOONSHOT_API_BASE", "{api_base}"),
+ ),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China
+ strip_model_prefix=False,
+ model_overrides=(
+ ("kimi-k2.5", {"temperature": 1.0}),
+ ),
+ ),
+
+ # === Local deployment (fallback: unknown api_base → assume local) ======
+
+ # vLLM / any OpenAI-compatible local server.
+ # If api_base is set but doesn't match a known gateway, we land here.
+ # Placed before Groq so vLLM wins the fallback when both are configured.
+ ProviderSpec(
+ name="vllm",
+ keywords=("vllm",),
+ env_key="HOSTED_VLLM_API_KEY",
+ display_name="vLLM/Local",
+ litellm_prefix="hosted_vllm", # Llama-3-8B → hosted_vllm/Llama-3-8B
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=False,
+ is_local=True,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="", # user must provide in config
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
+ # === Auxiliary (not a primary LLM provider) ============================
+
+ # Groq: mainly used for Whisper voice transcription, also usable for LLM.
+ # Needs "groq/" prefix for LiteLLM routing. Placed last — it rarely wins fallback.
+ ProviderSpec(
+ name="groq",
+ keywords=("groq",),
+ env_key="GROQ_API_KEY",
+ display_name="Groq",
+ litellm_prefix="groq", # llama3-8b-8192 → groq/llama3-8b-8192
+ skip_prefixes=("groq/",), # avoid double-prefix
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+)
+
+
+# ---------------------------------------------------------------------------
+# Lookup helpers
+# ---------------------------------------------------------------------------
+
+def find_by_model(model: str) -> ProviderSpec | None:
+ """Match a standard provider by model-name keyword (case-insensitive).
+ Skips gateways/local — those are matched by api_key/api_base instead."""
+ model_lower = model.lower()
+ for spec in PROVIDERS:
+ if spec.is_gateway or spec.is_local:
+ continue
+ if any(kw in model_lower for kw in spec.keywords):
+ return spec
+ return None
+
+
+def find_gateway(api_key: str | None, api_base: str | None) -> ProviderSpec | None:
+ """Detect gateway/local by api_key prefix or api_base substring.
+ Fallback: unknown api_base → treat as local (vLLM)."""
+ for spec in PROVIDERS:
+ if spec.detect_by_key_prefix and api_key and api_key.startswith(spec.detect_by_key_prefix):
+ return spec
+ if spec.detect_by_base_keyword and api_base and spec.detect_by_base_keyword in api_base:
+ return spec
+ if api_base:
+ return next((s for s in PROVIDERS if s.is_local), None)
+ return None
+
+
+def find_by_name(name: str) -> ProviderSpec | None:
+ """Find a provider spec by config field name, e.g. "dashscope"."""
+ for spec in PROVIDERS:
+ if spec.name == name:
+ return spec
+ return None
From c1dc8d3f554a4f299aec01d26f04cd91c89c68ec Mon Sep 17 00:00:00 2001
From: pinhua33
Date: Sun, 8 Feb 2026 16:33:46 +0800
Subject: [PATCH 019/506] fix: integrate OpenAI Codex provider with new
registry system
- Add OpenAI Codex ProviderSpec to registry.py
- Add openai_codex config field to ProvidersConfig in schema.py
- Mark Codex as OAuth-based (no API key required)
- Set appropriate default_api_base for Codex API
This integrates the Codex OAuth provider with the refactored
provider registry system introduced in upstream commit 299d8b3.
---
nanobot/config/schema.py | 1 +
nanobot/providers/registry.py | 18 ++++++++++++++++++
2 files changed, 19 insertions(+)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index ea8f8ba..1707797 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -81,6 +81,7 @@ class ProvidersConfig(BaseModel):
gemini: ProviderConfig = Field(default_factory=ProviderConfig)
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
+ openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) # AiHubMix API gateway
class GatewayConfig(BaseModel):
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index aa4a76e..d7226e7 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -141,6 +141,24 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
model_overrides=(),
),
+ # OpenAI Codex: uses OAuth, not API key.
+ ProviderSpec(
+ name="openai_codex",
+ keywords=("openai-codex", "codex"),
+ env_key="", # OAuth-based, no API key
+ display_name="OpenAI Codex",
+ litellm_prefix="", # Not routed through LiteLLM
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="codex",
+ default_api_base="https://chatgpt.com/backend-api",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
ProviderSpec(
name="deepseek",
From 08efe6ad3f1a1314765a037ac4c7d1a5757cd6eb Mon Sep 17 00:00:00 2001
From: pinhua33
Date: Sun, 8 Feb 2026 16:48:11 +0800
Subject: [PATCH 020/506] refactor: add OAuth support to provider registry
system
- Add is_oauth and oauth_provider fields to ProviderSpec
- Update _make_provider() to use registry for OAuth provider detection
- Update get_provider() to support OAuth providers (no API key required)
- Mark OpenAI Codex as OAuth-based provider in registry
This improves the provider registry architecture to support OAuth-based
authentication flows, making it extensible for future OAuth providers.
Benefits:
- OAuth providers are now registry-driven (not hardcoded)
- Extensible design: new OAuth providers only need registry entry
- Backward compatible: existing API key providers unaffected
- Clean separation: OAuth logic centralized in registry
---
nanobot/cli/commands.py | 31 ++++++++++++++++++++++---------
nanobot/config/schema.py | 10 +++++++---
nanobot/providers/registry.py | 6 ++++++
3 files changed, 35 insertions(+), 12 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 0732d48..855023a 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -173,20 +173,33 @@ This file stores important information that should persist across sessions.
def _make_provider(config):
- """Create LiteLLMProvider from config. Exits if no API key found."""
+ """Create provider from config. Exits if no credentials found."""
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
- from oauth_cli_kit import get_token as get_codex_token
+ from nanobot.providers.registry import PROVIDERS
+ from oauth_cli_kit import get_token as get_oauth_token
- p = config.get_provider()
model = config.agents.defaults.model
- if model.startswith("openai-codex/"):
- try:
- _ = get_codex_token()
- except Exception:
- console.print("Please run: [cyan]nanobot login --provider openai-codex[/cyan]")
+ model_lower = model.lower()
+
+ # Check for OAuth-based providers first (registry-driven)
+ for spec in PROVIDERS:
+ if spec.is_oauth and any(kw in model_lower for kw in spec.keywords):
+ # OAuth provider matched
+ try:
+ _ = get_oauth_token(spec.oauth_provider or spec.name)
+ except Exception:
+ console.print(f"Please run: [cyan]nanobot login --provider {spec.name}[/cyan]")
+ raise typer.Exit(1)
+ # Return appropriate OAuth provider class
+ if spec.name == "openai_codex":
+ return OpenAICodexProvider(default_model=model)
+ # Future OAuth providers can be added here
+ console.print(f"[red]Error: OAuth provider '{spec.name}' not fully implemented.[/red]")
raise typer.Exit(1)
- return OpenAICodexProvider(default_model=model)
+
+ # Standard API key-based providers
+ p = config.get_provider()
if not (p and p.api_key) and not model.startswith("bedrock/"):
console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section")
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 1707797..cde73f2 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -132,15 +132,19 @@ class Config(BaseSettings):
model_lower = (model or self.agents.defaults.model).lower()
# Match by keyword (order follows PROVIDERS registry)
+ # Note: OAuth providers don't require api_key, so we check is_oauth flag
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
- if p and any(kw in model_lower for kw in spec.keywords) and p.api_key:
- return p
+ if p and any(kw in model_lower for kw in spec.keywords):
+ # OAuth providers don't need api_key
+ if spec.is_oauth or p.api_key:
+ return p
# Fallback: gateways first, then others (follows registry order)
+ # OAuth providers are also valid fallbacks
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
- if p and p.api_key:
+ if p and (spec.is_oauth or p.api_key):
return p
return None
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index d7226e7..4ccf5da 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -51,6 +51,10 @@ class ProviderSpec:
# per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),)
model_overrides: tuple[tuple[str, dict[str, Any]], ...] = ()
+ # OAuth-based providers (e.g., OpenAI Codex) don't use API keys
+ is_oauth: bool = False # if True, uses OAuth flow instead of API key
+ oauth_provider: str = "" # OAuth provider name for token retrieval
+
@property
def label(self) -> str:
return self.display_name or self.name.title()
@@ -157,6 +161,8 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://chatgpt.com/backend-api",
strip_model_prefix=False,
model_overrides=(),
+ is_oauth=True, # OAuth-based authentication
+ oauth_provider="openai-codex", # OAuth provider identifier
),
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
From f49c639b74ced46df483ad12523580cd5e51da81 Mon Sep 17 00:00:00 2001
From: chaohuang-ai
Date: Sun, 8 Feb 2026 18:02:48 +0800
Subject: [PATCH 021/506] Update README.md
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 90ca9e3..8824570 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,8 @@
## 📢 News
-- **2026-02-08** 🔧 Refactored Providers — adding a new LLM provider only takes just 2 steps! Check [here](#providers).
-- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
+- **2026-02-08** 🔧 Refactored Providers — adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
+- **2026-02-07** 🚀 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
From 9e3823ae034e16287cebbe1b36e0c486e99139b5 Mon Sep 17 00:00:00 2001
From: chaohuang-ai
Date: Sun, 8 Feb 2026 18:03:00 +0800
Subject: [PATCH 022/506] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 8824570..d1ae7ce 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
## 📢 News
- **2026-02-08** 🔧 Refactored Providers — adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
-- **2026-02-07** 🚀 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
+- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
From 3675758a44d2c4d49dd867e776c18a764014975e Mon Sep 17 00:00:00 2001
From: chaohuang-ai
Date: Sun, 8 Feb 2026 18:10:24 +0800
Subject: [PATCH 023/506] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index d1ae7ce..a833dbe 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## 📢 News
-- **2026-02-08** 🔧 Refactored Providers — adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
+- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
From b6ec6a8a7686b8d3239bd9f363fa55490f9f9217 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 18:06:07 +0000
Subject: [PATCH 024/506] fix(dingtalk): security and resource fixes for
DingTalk channel
---
README.md | 10 +-
nanobot/channels/dingtalk.py | 195 +++++++++++++++++++----------------
2 files changed, 108 insertions(+), 97 deletions(-)
diff --git a/README.md b/README.md
index 8c5c387..326f253 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,423 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,429 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
@@ -293,10 +293,6 @@ nanobot gateway
Uses **WebSocket** long connection — no public IP required.
-```bash
-pip install nanobot-ai[feishu]
-```
-
**1. Create a Feishu bot**
- Visit [Feishu Open Platform](https://open.feishu.cn/app)
- Create a new app → Enable **Bot** capability
@@ -342,10 +338,6 @@ nanobot gateway
Uses **Stream Mode** — no public IP required.
-```bash
-pip install nanobot-ai[dingtalk]
-```
-
**1. Create a DingTalk bot**
- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
- Create a new app -> Add **Robot** capability
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 897e5be..72d3afd 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -2,30 +2,35 @@
import asyncio
import json
-import threading
import time
from typing import Any
from loguru import logger
import httpx
-from nanobot.bus.events import OutboundMessage, InboundMessage
+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,
+ DingTalkStreamClient,
Credential,
CallbackHandler,
CallbackMessage,
- AckMessage
+ 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):
@@ -33,127 +38,146 @@ 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 content based on message type
+
+ # Extract text content; fall back to raw dict if SDK object is empty
content = ""
if chatbot_msg.text:
content = chatbot_msg.text.content.strip()
- elif chatbot_msg.message_type == "text":
- # Fallback manual extraction if object not populated
- content = message.data.get("text", {}).get("content", "").strip()
-
if not content:
- logger.warning(f"Received empty or unsupported message type: {chatbot_msg.message_type}")
+ content = message.data.get("text", {}).get("content", "").strip()
+
+ if not content:
+ logger.warning(
+ f"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(f"Received DingTalk message from {sender_name} ({sender_id}): {content}")
- # Forward to Nanobot
- # We use asyncio.create_task to avoid blocking the ACK return
- asyncio.create_task(
+ # 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(f"Error processing DingTalk message: {e}")
- # Return OK to avoid retry loop from DingTalk server if it's a parsing error
+ # 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 (since SDK is mainly for receiving).
+ 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._loop: asyncio.AbstractEventLoop | None = 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")
+ 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._loop = asyncio.get_running_loop()
-
- logger.info(f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}...")
+ self._http = httpx.AsyncClient()
+
+ logger.info(
+ f"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)
-
- # Register using the chatbot topic standard for bots
- self._client.register_callback_handler(
- ChatbotMessage.TOPIC,
- handler
- )
-
+ self._client.register_callback_handler(ChatbotMessage.TOPIC, handler)
+
logger.info("DingTalk bot started with Stream Mode")
-
- # The client.start() method is an async infinite loop that handles the websocket connection
+
+ # client.start() is an async infinite loop handling the websocket connection
await self._client.start()
except Exception as e:
logger.exception(f"Failed to start DingTalk channel: {e}")
-
+
async def stop(self) -> None:
"""Stop the DingTalk bot."""
self._running = False
- # SDK doesn't expose a clean stop method that cancels loop immediately without private access
- pass
+ # 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
+ "appSecret": self.config.client_secret,
}
-
+
+ if not self._http:
+ logger.warning("DingTalk HTTP client not initialized, cannot refresh token")
+ return None
+
try:
- async with httpx.AsyncClient() as client:
- resp = await client.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
+ 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(f"Failed to get DingTalk access token: {e}")
return None
@@ -163,57 +187,52 @@ class DingTalkChannel(BaseChannel):
token = await self._get_access_token()
if not token:
return
-
- # This endpoint is for sending to a single user in a bot chat
+
+ # 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
- }
-
- # Convert markdown code blocks for basic compatibility if needed,
- # but DingTalk supports markdown loosely.
-
+
+ headers = {"x-acs-dingtalk-access-token": token}
+
data = {
"robotCode": self.config.client_id,
- "userIds": [msg.chat_id], # chat_id is the user's staffId/unionId
- "msgKey": "sampleMarkdown", # Using markdown template
+ "userIds": [msg.chat_id], # chat_id is the user's staffId
+ "msgKey": "sampleMarkdown",
"msgParam": json.dumps({
"text": msg.content,
- "title": "Nanobot Reply"
- })
+ "title": "Nanobot Reply",
+ }),
}
-
+
+ if not self._http:
+ logger.warning("DingTalk HTTP client not initialized, cannot send")
+ return
+
try:
- async with httpx.AsyncClient() as client:
- resp = await client.post(url, json=data, headers=headers)
- # Check 200 OK but also API error codes if any
- if resp.status_code != 200:
- logger.error(f"DingTalk send failed: {resp.text}")
- else:
- logger.debug(f"DingTalk message sent to {msg.chat_id}")
+ resp = await self._http.post(url, json=data, headers=headers)
+ if resp.status_code != 200:
+ logger.error(f"DingTalk send failed: {resp.text}")
+ else:
+ logger.debug(f"DingTalk message sent to {msg.chat_id}")
except Exception as e:
logger.error(f"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)."""
+ """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(f"DingTalk inbound: {content} from {sender_name}")
-
- # Correct InboundMessage usage based on events.py definition
- # @dataclass class InboundMessage:
- # channel: str, sender_id: str, chat_id: str, content: str, ...
- msg = InboundMessage(
- channel=self.name,
+ await self._handle_message(
sender_id=sender_id,
- chat_id=sender_id, # For private stats, chat_id is sender_id
+ chat_id=sender_id, # For private chat, chat_id == sender_id
content=str(content),
metadata={
"sender_name": sender_name,
- "platform": "dingtalk"
- }
+ "platform": "dingtalk",
+ },
)
- await self.bus.publish_inbound(msg)
except Exception as e:
logger.error(f"Error publishing DingTalk message: {e}")
From dfa173323c1641983b51223b1a6310b61e43e56b Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 18:23:43 +0000
Subject: [PATCH 025/506] =?UTF-8?q?refactor(cli):=20simplify=20input=20han?=
=?UTF-8?q?dling=20=E2=80=94=20drop=20prompt-toolkit,=20use=20readline?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
nanobot/cli/commands.py | 201 ++--------------------------------------
pyproject.toml | 1 -
2 files changed, 10 insertions(+), 192 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 5d198a5..c90ecde 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -6,7 +6,6 @@ import os
from pathlib import Path
import select
import sys
-from typing import Any
import typer
from rich.console import Console
@@ -21,12 +20,15 @@ app = typer.Typer(
)
console = Console()
-_READLINE: Any | None = None
+
+# ---------------------------------------------------------------------------
+# Lightweight CLI input: readline for arrow keys / history, termios for flush
+# ---------------------------------------------------------------------------
+
+_READLINE = None
_HISTORY_FILE: Path | None = None
_HISTORY_HOOK_REGISTERED = False
_USING_LIBEDIT = False
-_PROMPT_SESSION: Any | None = None
-_PROMPT_SESSION_LABEL: Any = None
def _flush_pending_tty_input() -> None:
@@ -40,7 +42,6 @@ def _flush_pending_tty_input() -> None:
try:
import termios
-
termios.tcflush(fd, termios.TCIFLUSH)
return
except Exception:
@@ -67,75 +68,16 @@ def _save_history() -> None:
def _enable_line_editing() -> None:
- """Best-effort enable readline/libedit line editing for arrow keys/history."""
+ """Enable readline for arrow keys, line editing, and persistent history."""
global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT
- global _PROMPT_SESSION, _PROMPT_SESSION_LABEL
history_file = Path.home() / ".nanobot" / "history" / "cli_history"
history_file.parent.mkdir(parents=True, exist_ok=True)
_HISTORY_FILE = history_file
- # Preferred path: prompt_toolkit handles wrapped wide-char rendering better.
- try:
- from prompt_toolkit import PromptSession
- from prompt_toolkit.formatted_text import ANSI
- from prompt_toolkit.history import FileHistory
- from prompt_toolkit.key_binding import KeyBindings
-
- key_bindings = KeyBindings()
-
- @key_bindings.add("enter")
- def _accept_input(event) -> None:
- _clear_visual_nav_state(event.current_buffer)
- event.current_buffer.validate_and_handle()
-
- @key_bindings.add("up")
- def _handle_up(event) -> None:
- count = event.arg if event.arg and event.arg > 0 else 1
- moved = _move_buffer_cursor_visual_from_render(
- buffer=event.current_buffer,
- event=event,
- delta=-1,
- count=count,
- )
- if not moved:
- event.current_buffer.history_backward(count=count)
- _clear_visual_nav_state(event.current_buffer)
-
- @key_bindings.add("down")
- def _handle_down(event) -> None:
- count = event.arg if event.arg and event.arg > 0 else 1
- moved = _move_buffer_cursor_visual_from_render(
- buffer=event.current_buffer,
- event=event,
- delta=1,
- count=count,
- )
- if not moved:
- event.current_buffer.history_forward(count=count)
- _clear_visual_nav_state(event.current_buffer)
-
- _PROMPT_SESSION = PromptSession(
- history=FileHistory(str(history_file)),
- multiline=True,
- wrap_lines=True,
- complete_while_typing=False,
- key_bindings=key_bindings,
- )
- _PROMPT_SESSION.default_buffer.on_text_changed += (
- lambda _event: _clear_visual_nav_state(_PROMPT_SESSION.default_buffer)
- )
- _PROMPT_SESSION_LABEL = ANSI("\x1b[1;34mYou:\x1b[0m ")
- _READLINE = None
- _USING_LIBEDIT = False
- return
- except Exception:
- _PROMPT_SESSION = None
- _PROMPT_SESSION_LABEL = None
-
try:
import readline
- except Exception:
+ except ImportError:
return
_READLINE = readline
@@ -170,137 +112,14 @@ def _prompt_text() -> str:
return "\001\033[1;34m\002You:\001\033[0m\002 "
-def _read_interactive_input() -> str:
- """Read user input with stable prompt rendering (sync fallback)."""
- return input(_prompt_text())
-
-
async def _read_interactive_input_async() -> str:
- """Read user input safely inside the interactive asyncio loop."""
- if _PROMPT_SESSION is not None:
- try:
- return await _PROMPT_SESSION.prompt_async(_PROMPT_SESSION_LABEL)
- except EOFError as exc:
- raise KeyboardInterrupt from exc
+ """Read user input with arrow keys and history (runs input() in a thread)."""
try:
- return await asyncio.to_thread(_read_interactive_input)
+ return await asyncio.to_thread(input, _prompt_text())
except EOFError as exc:
raise KeyboardInterrupt from exc
-def _choose_visual_rowcol(
- rowcol_to_yx: dict[tuple[int, int], tuple[int, int]],
- current_rowcol: tuple[int, int],
- delta: int,
- preferred_x: int | None = None,
-) -> tuple[tuple[int, int] | None, int | None]:
- """Choose next logical row/col by rendered screen coordinates."""
- if delta not in (-1, 1):
- return None, preferred_x
-
- current_yx = rowcol_to_yx.get(current_rowcol)
- if current_yx is None:
- same_row = [
- (rowcol, yx)
- for rowcol, yx in rowcol_to_yx.items()
- if rowcol[0] == current_rowcol[0]
- ]
- if not same_row:
- return None, preferred_x
- _, current_yx = min(same_row, key=lambda item: abs(item[0][1] - current_rowcol[1]))
-
- target_x = current_yx[1] if preferred_x is None else preferred_x
- target_y = current_yx[0] + delta
- candidates = [(rowcol, yx) for rowcol, yx in rowcol_to_yx.items() if yx[0] == target_y]
- if not candidates:
- return None, preferred_x
-
- best_rowcol, _ = min(
- candidates,
- key=lambda item: (abs(item[1][1] - target_x), item[1][1] < target_x, item[1][1]),
- )
- return best_rowcol, target_x
-
-
-def _clear_visual_nav_state(buffer: Any) -> None:
- """Reset cached vertical-navigation anchor state."""
- setattr(buffer, "_nanobot_visual_pref_x", None)
- setattr(buffer, "_nanobot_visual_last_dir", None)
- setattr(buffer, "_nanobot_visual_last_cursor", None)
- setattr(buffer, "_nanobot_visual_last_text", None)
-
-
-def _can_reuse_visual_anchor(buffer: Any, delta: int) -> bool:
- """Reuse anchor only for uninterrupted vertical navigation."""
- return (
- getattr(buffer, "_nanobot_visual_last_dir", None) == delta
- and getattr(buffer, "_nanobot_visual_last_cursor", None) == buffer.cursor_position
- and getattr(buffer, "_nanobot_visual_last_text", None) == buffer.text
- )
-
-
-def _remember_visual_anchor(buffer: Any, delta: int) -> None:
- """Remember current state as anchor baseline for repeated up/down."""
- setattr(buffer, "_nanobot_visual_last_dir", delta)
- setattr(buffer, "_nanobot_visual_last_cursor", buffer.cursor_position)
- setattr(buffer, "_nanobot_visual_last_text", buffer.text)
-
-
-def _move_buffer_cursor_visual_from_render(
- buffer: Any,
- event: Any,
- delta: int,
- count: int,
-) -> bool:
- """Move cursor across rendered screen rows (soft-wrap/CJK aware)."""
- try:
- window = event.app.layout.current_window
- render_info = getattr(window, "render_info", None)
- rowcol_to_yx = getattr(render_info, "_rowcol_to_yx", None)
- if not isinstance(rowcol_to_yx, dict) or not rowcol_to_yx:
- return False
- except Exception:
- return False
-
- moved_any = False
- preferred_x = (
- getattr(buffer, "_nanobot_visual_pref_x", None)
- if _can_reuse_visual_anchor(buffer, delta)
- else None
- )
- steps = max(1, count)
-
- for _ in range(steps):
- doc = buffer.document
- current_rowcol = (doc.cursor_position_row, doc.cursor_position_col)
- next_rowcol, preferred_x = _choose_visual_rowcol(
- rowcol_to_yx=rowcol_to_yx,
- current_rowcol=current_rowcol,
- delta=delta,
- preferred_x=preferred_x,
- )
- if next_rowcol is None:
- break
-
- try:
- new_position = doc.translate_row_col_to_index(*next_rowcol)
- except Exception:
- break
- if new_position == buffer.cursor_position:
- break
-
- buffer.cursor_position = new_position
- moved_any = True
-
- if moved_any:
- setattr(buffer, "_nanobot_visual_pref_x", preferred_x)
- _remember_visual_anchor(buffer, delta)
- else:
- _clear_visual_nav_state(buffer)
-
- return moved_any
-
-
def version_callback(value: bool):
if value:
console.print(f"{__logo__} nanobot v{__version__}")
diff --git a/pyproject.toml b/pyproject.toml
index 3669ee5..6fda084 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,7 +32,6 @@ dependencies = [
"python-telegram-bot[socks]>=21.0",
"lark-oapi>=1.0.0",
"socksio>=1.0.0",
- "prompt-toolkit>=3.0.47",
]
[project.optional-dependencies]
From b4217b26906d06d500d35de715801db42554ab25 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 18:26:06 +0000
Subject: [PATCH 026/506] chore: remove test file from tracking
---
tests/test_cli_input_minimal.py | 56 ---------------------------------
1 file changed, 56 deletions(-)
delete mode 100644 tests/test_cli_input_minimal.py
diff --git a/tests/test_cli_input_minimal.py b/tests/test_cli_input_minimal.py
deleted file mode 100644
index 4726ea3..0000000
--- a/tests/test_cli_input_minimal.py
+++ /dev/null
@@ -1,56 +0,0 @@
-import builtins
-
-import nanobot.cli.commands as commands
-
-
-def test_read_interactive_input_uses_plain_input(monkeypatch) -> None:
- captured: dict[str, str] = {}
- def fake_input(prompt: str = "") -> str:
- captured["prompt"] = prompt
- return "hello"
-
- monkeypatch.setattr(builtins, "input", fake_input)
- monkeypatch.setattr(commands, "_PROMPT_SESSION", None)
- monkeypatch.setattr(commands, "_READLINE", None)
-
- value = commands._read_interactive_input()
-
- assert value == "hello"
- assert captured["prompt"] == "You: "
-
-
-def test_read_interactive_input_prefers_prompt_session(monkeypatch) -> None:
- captured: dict[str, object] = {}
-
- class FakePromptSession:
- async def prompt_async(self, label: object) -> str:
- captured["label"] = label
- return "hello"
-
- monkeypatch.setattr(commands, "_PROMPT_SESSION", FakePromptSession())
- monkeypatch.setattr(commands, "_PROMPT_SESSION_LABEL", "LBL")
-
- value = __import__("asyncio").run(commands._read_interactive_input_async())
-
- assert value == "hello"
- assert captured["label"] == "LBL"
-
-
-def test_prompt_text_for_readline_modes(monkeypatch) -> None:
- monkeypatch.setattr(commands, "_READLINE", object())
- monkeypatch.setattr(commands, "_USING_LIBEDIT", True)
- assert commands._prompt_text() == "\033[1;34mYou:\033[0m "
-
- monkeypatch.setattr(commands, "_USING_LIBEDIT", False)
- assert "\001" in commands._prompt_text()
-
-
-def test_flush_pending_tty_input_skips_non_tty(monkeypatch) -> None:
- class FakeStdin:
- def fileno(self) -> int:
- return 0
-
- monkeypatch.setattr(commands.sys, "stdin", FakeStdin())
- monkeypatch.setattr(commands.os, "isatty", lambda _fd: False)
-
- commands._flush_pending_tty_input()
From 2931694eb893b4d108e78df79c40621122589e8f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 18:37:41 +0000
Subject: [PATCH 027/506] fix: preserve reasoning_content in conversation
history for thinking models
---
README.md | 2 +-
nanobot/agent/context.py | 8 +++++++-
nanobot/agent/loop.py | 6 ++++--
nanobot/providers/base.py | 1 +
nanobot/providers/litellm_provider.py | 3 +++
5 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 326f253..d3dcaf7 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,429 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,437 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 3ea6c04..d807854 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -207,7 +207,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md"""
self,
messages: list[dict[str, Any]],
content: str | None,
- tool_calls: list[dict[str, Any]] | None = None
+ tool_calls: list[dict[str, Any]] | None = None,
+ reasoning_content: str | None = None,
) -> list[dict[str, Any]]:
"""
Add an assistant message to the message list.
@@ -216,6 +217,7 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md"""
messages: Current message list.
content: Message content.
tool_calls: Optional tool calls.
+ reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.).
Returns:
Updated message list.
@@ -225,5 +227,9 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md"""
if tool_calls:
msg["tool_calls"] = tool_calls
+ # Thinking models reject history without this
+ if reasoning_content:
+ msg["reasoning_content"] = reasoning_content
+
messages.append(msg)
return messages
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index a65f3a5..72ea86a 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -213,7 +213,8 @@ class AgentLoop:
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
- messages, response.content, tool_call_dicts
+ messages, response.content, tool_call_dicts,
+ reasoning_content=response.reasoning_content,
)
# Execute tools
@@ -317,7 +318,8 @@ class AgentLoop:
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
- messages, response.content, tool_call_dicts
+ messages, response.content, tool_call_dicts,
+ reasoning_content=response.reasoning_content,
)
for tool_call in response.tool_calls:
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index 08e44ac..c69c38b 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -20,6 +20,7 @@ class LLMResponse:
tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict)
+ reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc.
@property
def has_tool_calls(self) -> bool:
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 5e9c22f..621a71d 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -183,11 +183,14 @@ class LiteLLMProvider(LLMProvider):
"total_tokens": response.usage.total_tokens,
}
+ reasoning_content = getattr(message, "reasoning_content", None)
+
return LLMResponse(
content=message.content,
tool_calls=tool_calls,
finish_reason=choice.finish_reason or "stop",
usage=usage,
+ reasoning_content=reasoning_content,
)
def get_default_model(self) -> str:
From eb2fbf80dac6a6ea0f21bd8257bea431fffc16e0 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 19:31:25 +0000
Subject: [PATCH 028/506] fix: use config key to detect provider, prevent
api_base misidentifying as vLLM
---
README.md | 2 +-
nanobot/cli/commands.py | 1 +
nanobot/config/schema.py | 35 ++++++++++++++-------
nanobot/providers/litellm_provider.py | 45 +++++++++++++--------------
nanobot/providers/registry.py | 33 +++++++++++++++-----
5 files changed, 72 insertions(+), 44 deletions(-)
diff --git a/README.md b/README.md
index d3dcaf7..cb2c64a 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,437 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,448 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index c90ecde..59ed9e1 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -263,6 +263,7 @@ def _make_provider(config):
api_base=config.get_api_base(),
default_model=model,
extra_headers=p.extra_headers if p else None,
+ provider_name=config.get_provider_name(),
)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index ea2f1c1..edea307 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -134,8 +134,8 @@ class Config(BaseSettings):
"""Get expanded workspace path."""
return Path(self.agents.defaults.workspace).expanduser()
- def get_provider(self, model: str | None = None) -> ProviderConfig | None:
- """Get matched provider config (api_key, api_base, extra_headers). Falls back to first available."""
+ def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]:
+ """Match provider config and its registry name. Returns (config, spec_name)."""
from nanobot.providers.registry import PROVIDERS
model_lower = (model or self.agents.defaults.model).lower()
@@ -143,14 +143,24 @@ class Config(BaseSettings):
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
if p and any(kw in model_lower for kw in spec.keywords) and p.api_key:
- return p
+ return p, spec.name
# Fallback: gateways first, then others (follows registry order)
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
if p and p.api_key:
- return p
- return None
+ return p, spec.name
+ return None, None
+
+ def get_provider(self, model: str | None = None) -> ProviderConfig | None:
+ """Get matched provider config (api_key, api_base, extra_headers). Falls back to first available."""
+ p, _ = self._match_provider(model)
+ return p
+
+ def get_provider_name(self, model: str | None = None) -> str | None:
+ """Get the registry name of the matched provider (e.g. "deepseek", "openrouter")."""
+ _, name = self._match_provider(model)
+ return name
def get_api_key(self, model: str | None = None) -> str | None:
"""Get API key for the given model. Falls back to first available key."""
@@ -159,15 +169,16 @@ class Config(BaseSettings):
def get_api_base(self, model: str | None = None) -> str | None:
"""Get API base URL for the given model. Applies default URLs for known gateways."""
- from nanobot.providers.registry import PROVIDERS
- p = self.get_provider(model)
+ from nanobot.providers.registry import find_by_name
+ p, name = self._match_provider(model)
if p and p.api_base:
return p.api_base
- # Only gateways get a default URL here. Standard providers (like Moonshot)
- # handle their base URL via env vars in _setup_env, NOT via api_base —
- # otherwise find_gateway() would misdetect them as local/vLLM.
- for spec in PROVIDERS:
- if spec.is_gateway and spec.default_api_base and p == getattr(self.providers, spec.name, None):
+ # Only gateways get a default api_base here. Standard providers
+ # (like Moonshot) set their base URL via env vars in _setup_env
+ # to avoid polluting the global 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
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 621a71d..33c300a 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -26,18 +26,16 @@ class LiteLLMProvider(LLMProvider):
api_base: str | None = None,
default_model: str = "anthropic/claude-opus-4-5",
extra_headers: dict[str, str] | None = None,
+ provider_name: str | None = None,
):
super().__init__(api_key, api_base)
self.default_model = default_model
self.extra_headers = extra_headers or {}
- # Detect gateway / local deployment from api_key and api_base
- self._gateway = find_gateway(api_key, api_base)
-
- # Backwards-compatible flags (used by tests and possibly external code)
- self.is_openrouter = bool(self._gateway and self._gateway.name == "openrouter")
- self.is_aihubmix = bool(self._gateway and self._gateway.name == "aihubmix")
- self.is_vllm = bool(self._gateway and self._gateway.is_local)
+ # Detect gateway / local deployment.
+ # provider_name (from config key) is the primary signal;
+ # api_key / api_base are fallback for auto-detection.
+ self._gateway = find_gateway(provider_name, api_key, api_base)
# Configure environment variables
if api_key:
@@ -51,23 +49,24 @@ class LiteLLMProvider(LLMProvider):
def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None:
"""Set environment variables based on detected provider."""
- if self._gateway:
- # Gateway / local: direct set (not setdefault)
- os.environ[self._gateway.env_key] = api_key
+ spec = self._gateway or find_by_model(model)
+ if not spec:
return
-
- # Standard provider: match by model name
- spec = find_by_model(model)
- if spec:
+
+ # Gateway/local overrides existing env; standard provider doesn't
+ if self._gateway:
+ os.environ[spec.env_key] = api_key
+ else:
os.environ.setdefault(spec.env_key, api_key)
- # Resolve env_extras placeholders:
- # {api_key} → user's API key
- # {api_base} → user's api_base, falling back to spec.default_api_base
- effective_base = api_base or spec.default_api_base
- for env_name, env_val in spec.env_extras:
- resolved = env_val.replace("{api_key}", api_key)
- resolved = resolved.replace("{api_base}", effective_base)
- os.environ.setdefault(env_name, resolved)
+
+ # Resolve env_extras placeholders:
+ # {api_key} → user's API key
+ # {api_base} → user's api_base, falling back to spec.default_api_base
+ effective_base = api_base or spec.default_api_base
+ for env_name, env_val in spec.env_extras:
+ resolved = env_val.replace("{api_key}", api_key)
+ resolved = resolved.replace("{api_base}", effective_base)
+ os.environ.setdefault(env_name, resolved)
def _resolve_model(self, model: str) -> str:
"""Resolve model name by applying provider/gateway prefixes."""
@@ -131,7 +130,7 @@ class LiteLLMProvider(LLMProvider):
# Apply model-specific overrides (e.g. kimi-k2.5 temperature)
self._apply_model_overrides(model, kwargs)
- # Pass api_base directly for custom endpoints (vLLM, etc.)
+ # Pass api_base for custom endpoints
if self.api_base:
kwargs["api_base"] = self.api_base
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index aa4a76e..57db4dd 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -241,11 +241,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
),
),
- # === Local deployment (fallback: unknown api_base → assume local) ======
+ # === Local deployment (matched by config key, NOT by api_base) =========
# vLLM / any OpenAI-compatible local server.
- # If api_base is set but doesn't match a known gateway, we land here.
- # Placed before Groq so vLLM wins the fallback when both are configured.
+ # Detected when config key is "vllm" (provider_name="vllm").
ProviderSpec(
name="vllm",
keywords=("vllm",),
@@ -302,16 +301,34 @@ def find_by_model(model: str) -> ProviderSpec | None:
return None
-def find_gateway(api_key: str | None, api_base: str | None) -> ProviderSpec | None:
- """Detect gateway/local by api_key prefix or api_base substring.
- Fallback: unknown api_base → treat as local (vLLM)."""
+def find_gateway(
+ provider_name: str | None = None,
+ api_key: str | None = None,
+ api_base: str | None = None,
+) -> ProviderSpec | None:
+ """Detect gateway/local provider.
+
+ Priority:
+ 1. provider_name — if it maps to a gateway/local spec, use it directly.
+ 2. api_key prefix — e.g. "sk-or-" → OpenRouter.
+ 3. api_base keyword — e.g. "aihubmix" in URL → AiHubMix.
+
+ A standard provider with a custom api_base (e.g. DeepSeek behind a proxy)
+ will NOT be mistaken for vLLM — the old fallback is gone.
+ """
+ # 1. Direct match by config key
+ if provider_name:
+ spec = find_by_name(provider_name)
+ if spec and (spec.is_gateway or spec.is_local):
+ return spec
+
+ # 2. Auto-detect by api_key prefix / api_base keyword
for spec in PROVIDERS:
if spec.detect_by_key_prefix and api_key and api_key.startswith(spec.detect_by_key_prefix):
return spec
if spec.detect_by_base_keyword and api_base and spec.detect_by_base_keyword in api_base:
return spec
- if api_base:
- return next((s for s in PROVIDERS if s.is_local), None)
+
return None
From 25e17717c20cd67d52e65f846efa7fc788c1bfc8 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Feb 2026 19:36:53 +0000
Subject: [PATCH 029/506] fix: restore terminal state on Ctrl+C exit in agent
interactive mode
---
nanobot/cli/commands.py | 34 +++++++++++++++++++++++++++++++++-
1 file changed, 33 insertions(+), 1 deletion(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 59ed9e1..fed9bbe 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -3,6 +3,7 @@
import asyncio
import atexit
import os
+import signal
from pathlib import Path
import select
import sys
@@ -29,6 +30,7 @@ _READLINE = None
_HISTORY_FILE: Path | None = None
_HISTORY_HOOK_REGISTERED = False
_USING_LIBEDIT = False
+_SAVED_TERM_ATTRS = None # original termios settings, restored on exit
def _flush_pending_tty_input() -> None:
@@ -67,9 +69,27 @@ def _save_history() -> None:
return
+def _restore_terminal() -> None:
+ """Restore terminal to its original state (echo, line buffering, etc.)."""
+ if _SAVED_TERM_ATTRS is None:
+ return
+ try:
+ import termios
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
+ except Exception:
+ pass
+
+
def _enable_line_editing() -> None:
"""Enable readline for arrow keys, line editing, and persistent history."""
- global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT
+ global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT, _SAVED_TERM_ATTRS
+
+ # Save terminal state before readline touches it
+ 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)
@@ -421,6 +441,16 @@ def agent(
# Interactive mode
_enable_line_editing()
console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n")
+
+ # input() runs in a worker thread that can't be cancelled.
+ # Without this handler, asyncio.run() would hang waiting for it.
+ def _exit_on_sigint(signum, frame):
+ _save_history()
+ _restore_terminal()
+ console.print("\nGoodbye!")
+ os._exit(0)
+
+ signal.signal(signal.SIGINT, _exit_on_sigint)
async def run_interactive():
while True:
@@ -433,6 +463,8 @@ def agent(
response = await agent_loop.process_direct(user_input, session_id)
console.print(f"\n{__logo__} {response}\n")
except KeyboardInterrupt:
+ _save_history()
+ _restore_terminal()
console.print("\nGoodbye!")
break
From 0a2d557268c98bc5d9290aabbd8b0604b4e0d717 Mon Sep 17 00:00:00 2001
From: Chris Alexander <2815297+chris-alexander@users.noreply.github.com>
Date: Sun, 8 Feb 2026 20:50:31 +0000
Subject: [PATCH 030/506] Improve agent CLI chat UX with markdown output and
clearer interaction feedback
---
nanobot/cli/commands.py | 268 ++++++++++++++++++++++++----------------
1 file changed, 161 insertions(+), 107 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index fed9bbe..4ae2132 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -10,7 +10,10 @@ import sys
import typer
from rich.console import Console
+from rich.markdown import Markdown
+from rich.panel import Panel
from rich.table import Table
+from rich.text import Text
from nanobot import __version__, __logo__
@@ -21,6 +24,30 @@ app = typer.Typer(
)
console = Console()
+EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
+
+
+def _print_agent_response(response: str, render_markdown: bool) -> None:
+ """Render assistant response with consistent terminal styling."""
+ content = response or ""
+ body = Markdown(content) if render_markdown else Text(content)
+ console.print()
+ console.print(
+ Panel(
+ body,
+ title=f"{__logo__} Nanobot",
+ title_align="left",
+ border_style="cyan",
+ padding=(0, 1),
+ )
+ )
+ console.print()
+
+
+def _is_exit_command(command: str) -> bool:
+ """Return True when input should end interactive chat."""
+ return command.lower() in EXIT_COMMANDS
+
# ---------------------------------------------------------------------------
# Lightweight CLI input: readline for arrow keys / history, termios for flush
@@ -44,6 +71,7 @@ def _flush_pending_tty_input() -> None:
try:
import termios
+
termios.tcflush(fd, termios.TCIFLUSH)
return
except Exception:
@@ -75,6 +103,7 @@ def _restore_terminal() -> None:
return
try:
import termios
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
except Exception:
pass
@@ -87,6 +116,7 @@ def _enable_line_editing() -> None:
# Save terminal state before readline touches it
try:
import termios
+
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
except Exception:
pass
@@ -148,9 +178,7 @@ def version_callback(value: bool):
@app.callback()
def main(
- version: bool = typer.Option(
- None, "--version", "-v", callback=version_callback, is_eager=True
- ),
+ version: bool = typer.Option(None, "--version", "-v", callback=version_callback, is_eager=True),
):
"""nanobot - Personal AI Assistant."""
pass
@@ -167,34 +195,34 @@ def onboard():
from nanobot.config.loader import get_config_path, save_config
from nanobot.config.schema import Config
from nanobot.utils.helpers import get_workspace_path
-
+
config_path = get_config_path()
-
+
if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
if not typer.confirm("Overwrite?"):
raise typer.Exit()
-
+
# Create default config
config = Config()
save_config(config)
console.print(f"[green]✓[/green] Created config at {config_path}")
-
+
# Create workspace
workspace = get_workspace_path()
console.print(f"[green]✓[/green] Created workspace at {workspace}")
-
+
# Create default bootstrap files
_create_workspace_templates(workspace)
-
+
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
console.print(" Get one at: https://openrouter.ai/keys")
- console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]")
- console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
-
-
+ console.print(' 2. Chat: [cyan]nanobot agent -m "Hello!"[/cyan]')
+ console.print(
+ "\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]"
+ )
def _create_workspace_templates(workspace: Path):
@@ -238,13 +266,13 @@ Information about the user goes here.
- Language: (your preferred language)
""",
}
-
+
for filename, content in templates.items():
file_path = workspace / filename
if not file_path.exists():
file_path.write_text(content)
console.print(f" [dim]Created {filename}[/dim]")
-
+
# Create memory directory and MEMORY.md
memory_dir = workspace / "memory"
memory_dir.mkdir(exist_ok=True)
@@ -272,6 +300,7 @@ This file stores important information that should persist across sessions.
def _make_provider(config):
"""Create LiteLLMProvider from config. Exits if no API key found."""
from nanobot.providers.litellm_provider import LiteLLMProvider
+
p = config.get_provider()
model = config.agents.defaults.model
if not (p and p.api_key) and not model.startswith("bedrock/"):
@@ -306,22 +335,23 @@ def gateway(
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
-
+
if verbose:
import logging
+
logging.basicConfig(level=logging.DEBUG)
-
+
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
-
+
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
-
+
# Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
-
+
# Create agent with cron service
agent = AgentLoop(
bus=bus,
@@ -335,7 +365,7 @@ def gateway(
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
)
-
+
# Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent."""
@@ -347,40 +377,44 @@ def gateway(
)
if job.payload.deliver and job.payload.to:
from nanobot.bus.events import OutboundMessage
- await bus.publish_outbound(OutboundMessage(
- channel=job.payload.channel or "cli",
- chat_id=job.payload.to,
- content=response or ""
- ))
+
+ await bus.publish_outbound(
+ OutboundMessage(
+ channel=job.payload.channel or "cli",
+ chat_id=job.payload.to,
+ content=response or "",
+ )
+ )
return response
+
cron.on_job = on_cron_job
-
+
# Create heartbeat service
async def on_heartbeat(prompt: str) -> str:
"""Execute heartbeat through the agent."""
return await agent.process_direct(prompt, session_key="heartbeat")
-
+
heartbeat = HeartbeatService(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes
- enabled=True
+ enabled=True,
)
-
+
# Create channel manager
channels = ChannelManager(config, bus, session_manager=session_manager)
-
+
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():
try:
await cron.start()
@@ -395,12 +429,10 @@ def gateway(
cron.stop()
agent.stop()
await channels.stop_all()
-
+
asyncio.run(run())
-
-
# ============================================================================
# Agent Commands
# ============================================================================
@@ -410,17 +442,29 @@ def gateway(
def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
+ markdown: bool = typer.Option(
+ True, "--markdown/--no-markdown", help="Render assistant output as Markdown"
+ ),
+ logs: bool = typer.Option(
+ False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"
+ ),
):
"""Interact with the agent directly."""
from nanobot.config.loader import load_config
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
-
+ from loguru import logger
+
config = load_config()
-
+
bus = MessageBus()
provider = _make_provider(config)
-
+
+ if logs:
+ logger.enable("nanobot")
+ else:
+ logger.disable("nanobot")
+
agent_loop = AgentLoop(
bus=bus,
provider=provider,
@@ -429,13 +473,14 @@ def agent(
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
)
-
+
if message:
# Single message mode
async def run_once():
- response = await agent_loop.process_direct(message, session_id)
- console.print(f"\n{__logo__} {response}")
-
+ with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"):
+ response = await agent_loop.process_direct(message, session_id)
+ _print_agent_response(response, render_markdown=markdown)
+
asyncio.run(run_once())
else:
# Interactive mode
@@ -451,23 +496,32 @@ def agent(
os._exit(0)
signal.signal(signal.SIGINT, _exit_on_sigint)
-
+
async def run_interactive():
while True:
try:
_flush_pending_tty_input()
user_input = await _read_interactive_input_async()
- if not user_input.strip():
+ command = user_input.strip()
+ if not command:
continue
-
- response = await agent_loop.process_direct(user_input, session_id)
- console.print(f"\n{__logo__} {response}\n")
+
+ if _is_exit_command(command):
+ console.print("\nGoodbye!")
+ break
+
+ with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"):
+ response = await agent_loop.process_direct(user_input, session_id)
+ _print_agent_response(response, render_markdown=markdown)
except KeyboardInterrupt:
_save_history()
_restore_terminal()
console.print("\nGoodbye!")
break
-
+ except EOFError:
+ console.print("\nGoodbye!")
+ break
+
asyncio.run(run_interactive())
@@ -494,27 +548,15 @@ def channels_status():
# WhatsApp
wa = config.channels.whatsapp
- table.add_row(
- "WhatsApp",
- "✓" if wa.enabled else "✗",
- wa.bridge_url
- )
+ 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
- )
-
+ table.add_row("Discord", "✓" if dc.enabled else "✗", dc.gateway_url)
+
# 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
- )
+ table.add_row("Telegram", "✓" if tg.enabled else "✗", tg_config)
console.print(table)
@@ -523,57 +565,57 @@ def _get_bridge_dir() -> Path:
"""Get the bridge directory, setting it up if needed."""
import shutil
import subprocess
-
+
# User's bridge location
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)
-
+
# Find source bridge: first check package data, then source dir
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
-
+
source = None
if (pkg_bridge / "package.json").exists():
source = pkg_bridge
elif (src_bridge / "package.json").exists():
source = src_bridge
-
+
if not source:
console.print("[red]Bridge source not found.[/red]")
console.print("Try reinstalling: pip install --force-reinstall nanobot")
raise typer.Exit(1)
-
+
console.print(f"{__logo__} Setting up bridge...")
-
+
# Copy to user directory
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
@@ -581,12 +623,12 @@ def _get_bridge_dir() -> Path:
def channels_login():
"""Link device via QR code."""
import subprocess
-
+
bridge_dir = _get_bridge_dir()
-
+
console.print(f"{__logo__} Starting bridge...")
console.print("Scan the QR code to connect.\n")
-
+
try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True)
except subprocess.CalledProcessError as e:
@@ -610,24 +652,25 @@ def cron_list(
"""List scheduled jobs."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
-
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
jobs = service.list_jobs(include_disabled=all)
-
+
if not jobs:
console.print("No scheduled jobs.")
return
-
+
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")
-
+
import time
+
for job in jobs:
# Format schedule
if job.schedule.kind == "every":
@@ -636,17 +679,19 @@ def cron_list(
sched = job.schedule.expr or ""
else:
sched = "one-time"
-
+
# Format next run
next_run = ""
if job.state.next_run_at_ms:
- next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
+ next_time = time.strftime(
+ "%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)
+ )
next_run = next_time
-
+
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)
@@ -659,13 +704,15 @@ def cron_add(
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')"),
+ channel: str = typer.Option(
+ None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"
+ ),
):
"""Add a scheduled job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
-
+
# Determine schedule type
if every:
schedule = CronSchedule(kind="every", every_ms=every * 1000)
@@ -673,15 +720,16 @@ def cron_add(
schedule = CronSchedule(kind="cron", expr=cron_expr)
elif at:
import datetime
+
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_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
job = service.add_job(
name=name,
schedule=schedule,
@@ -690,7 +738,7 @@ def cron_add(
to=to,
channel=channel,
)
-
+
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
@@ -701,10 +749,10 @@ def cron_remove(
"""Remove a scheduled job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
-
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
if service.remove_job(job_id):
console.print(f"[green]✓[/green] Removed job {job_id}")
else:
@@ -719,10 +767,10 @@ def cron_enable(
"""Enable or disable a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
-
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
job = service.enable_job(job_id, enabled=not disable)
if job:
status = "disabled" if disable else "enabled"
@@ -739,13 +787,13 @@ def cron_run(
"""Manually run a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
-
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
async def run():
return await service.run_job(job_id, force=force)
-
+
if asyncio.run(run()):
console.print(f"[green]✓[/green] Job executed")
else:
@@ -768,14 +816,18 @@ def status():
console.print(f"{__logo__} nanobot Status\n")
- console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
- console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
+ 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}")
-
+
# Check API keys from registry
for spec in PROVIDERS:
p = getattr(config.providers, spec.name, None)
@@ -789,7 +841,9 @@ def status():
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]'}")
+ console.print(
+ f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}"
+ )
if __name__ == "__main__":
From 9c6ffa0d562de1ba7e776ffbe352be637c6ebdc1 Mon Sep 17 00:00:00 2001
From: Chris Alexander <2815297+chris-alexander@users.noreply.github.com>
Date: Sun, 8 Feb 2026 21:07:02 +0000
Subject: [PATCH 031/506] Trim CLI patch to remove unrelated whitespace churn
---
nanobot/cli/commands.py | 262 +++++++++++++++++++---------------------
1 file changed, 126 insertions(+), 136 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 4ae2132..875eb90 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -26,29 +26,6 @@ app = typer.Typer(
console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
-
-def _print_agent_response(response: str, render_markdown: bool) -> None:
- """Render assistant response with consistent terminal styling."""
- content = response or ""
- body = Markdown(content) if render_markdown else Text(content)
- console.print()
- console.print(
- Panel(
- body,
- title=f"{__logo__} Nanobot",
- title_align="left",
- border_style="cyan",
- padding=(0, 1),
- )
- )
- console.print()
-
-
-def _is_exit_command(command: str) -> bool:
- """Return True when input should end interactive chat."""
- return command.lower() in EXIT_COMMANDS
-
-
# ---------------------------------------------------------------------------
# Lightweight CLI input: readline for arrow keys / history, termios for flush
# ---------------------------------------------------------------------------
@@ -71,7 +48,6 @@ def _flush_pending_tty_input() -> None:
try:
import termios
-
termios.tcflush(fd, termios.TCIFLUSH)
return
except Exception:
@@ -103,7 +79,6 @@ def _restore_terminal() -> None:
return
try:
import termios
-
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS)
except Exception:
pass
@@ -116,7 +91,6 @@ def _enable_line_editing() -> None:
# Save terminal state before readline touches it
try:
import termios
-
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
except Exception:
pass
@@ -162,6 +136,28 @@ def _prompt_text() -> str:
return "\001\033[1;34m\002You:\001\033[0m\002 "
+def _print_agent_response(response: str, render_markdown: bool) -> None:
+ """Render assistant response with consistent terminal styling."""
+ content = response or ""
+ body = Markdown(content) if render_markdown else Text(content)
+ console.print()
+ console.print(
+ Panel(
+ body,
+ title=f"{__logo__} Nanobot",
+ title_align="left",
+ border_style="cyan",
+ padding=(0, 1),
+ )
+ )
+ console.print()
+
+
+def _is_exit_command(command: str) -> bool:
+ """Return True when input should end interactive chat."""
+ return command.lower() in EXIT_COMMANDS
+
+
async def _read_interactive_input_async() -> str:
"""Read user input with arrow keys and history (runs input() in a thread)."""
try:
@@ -178,7 +174,9 @@ def version_callback(value: bool):
@app.callback()
def main(
- version: bool = typer.Option(None, "--version", "-v", callback=version_callback, is_eager=True),
+ version: bool = typer.Option(
+ None, "--version", "-v", callback=version_callback, is_eager=True
+ ),
):
"""nanobot - Personal AI Assistant."""
pass
@@ -195,34 +193,34 @@ def onboard():
from nanobot.config.loader import get_config_path, save_config
from nanobot.config.schema import Config
from nanobot.utils.helpers import get_workspace_path
-
+
config_path = get_config_path()
-
+
if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
if not typer.confirm("Overwrite?"):
raise typer.Exit()
-
+
# Create default config
config = Config()
save_config(config)
console.print(f"[green]✓[/green] Created config at {config_path}")
-
+
# Create workspace
workspace = get_workspace_path()
console.print(f"[green]✓[/green] Created workspace at {workspace}")
-
+
# Create default bootstrap files
_create_workspace_templates(workspace)
-
+
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
console.print(" Get one at: https://openrouter.ai/keys")
- console.print(' 2. Chat: [cyan]nanobot agent -m "Hello!"[/cyan]')
- console.print(
- "\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]"
- )
+ console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]")
+ console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
+
+
def _create_workspace_templates(workspace: Path):
@@ -266,13 +264,13 @@ Information about the user goes here.
- Language: (your preferred language)
""",
}
-
+
for filename, content in templates.items():
file_path = workspace / filename
if not file_path.exists():
file_path.write_text(content)
console.print(f" [dim]Created {filename}[/dim]")
-
+
# Create memory directory and MEMORY.md
memory_dir = workspace / "memory"
memory_dir.mkdir(exist_ok=True)
@@ -300,7 +298,6 @@ This file stores important information that should persist across sessions.
def _make_provider(config):
"""Create LiteLLMProvider from config. Exits if no API key found."""
from nanobot.providers.litellm_provider import LiteLLMProvider
-
p = config.get_provider()
model = config.agents.defaults.model
if not (p and p.api_key) and not model.startswith("bedrock/"):
@@ -335,23 +332,22 @@ def gateway(
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
-
+
if verbose:
import logging
-
logging.basicConfig(level=logging.DEBUG)
-
+
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
-
+
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
-
+
# Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
-
+
# Create agent with cron service
agent = AgentLoop(
bus=bus,
@@ -365,7 +361,7 @@ def gateway(
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
)
-
+
# Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent."""
@@ -377,44 +373,40 @@ def gateway(
)
if job.payload.deliver and job.payload.to:
from nanobot.bus.events import OutboundMessage
-
- await bus.publish_outbound(
- OutboundMessage(
- channel=job.payload.channel or "cli",
- chat_id=job.payload.to,
- content=response or "",
- )
- )
+ await bus.publish_outbound(OutboundMessage(
+ channel=job.payload.channel or "cli",
+ chat_id=job.payload.to,
+ content=response or ""
+ ))
return response
-
cron.on_job = on_cron_job
-
+
# Create heartbeat service
async def on_heartbeat(prompt: str) -> str:
"""Execute heartbeat through the agent."""
return await agent.process_direct(prompt, session_key="heartbeat")
-
+
heartbeat = HeartbeatService(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes
- enabled=True,
+ enabled=True
)
-
+
# Create channel manager
channels = ChannelManager(config, bus, session_manager=session_manager)
-
+
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():
try:
await cron.start()
@@ -429,10 +421,12 @@ def gateway(
cron.stop()
agent.stop()
await channels.stop_all()
-
+
asyncio.run(run())
+
+
# ============================================================================
# Agent Commands
# ============================================================================
@@ -442,21 +436,17 @@ def gateway(
def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
- markdown: bool = typer.Option(
- True, "--markdown/--no-markdown", help="Render assistant output as Markdown"
- ),
- logs: bool = typer.Option(
- False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"
- ),
+ markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
+ logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
"""Interact with the agent directly."""
from nanobot.config.loader import load_config
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from loguru import logger
-
+
config = load_config()
-
+
bus = MessageBus()
provider = _make_provider(config)
@@ -464,7 +454,7 @@ def agent(
logger.enable("nanobot")
else:
logger.disable("nanobot")
-
+
agent_loop = AgentLoop(
bus=bus,
provider=provider,
@@ -473,14 +463,14 @@ def agent(
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
)
-
+
if message:
# Single message mode
async def run_once():
with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"):
response = await agent_loop.process_direct(message, session_id)
_print_agent_response(response, render_markdown=markdown)
-
+
asyncio.run(run_once())
else:
# Interactive mode
@@ -496,7 +486,7 @@ def agent(
os._exit(0)
signal.signal(signal.SIGINT, _exit_on_sigint)
-
+
async def run_interactive():
while True:
try:
@@ -509,7 +499,7 @@ def agent(
if _is_exit_command(command):
console.print("\nGoodbye!")
break
-
+
with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"):
response = await agent_loop.process_direct(user_input, session_id)
_print_agent_response(response, render_markdown=markdown)
@@ -521,7 +511,7 @@ def agent(
except EOFError:
console.print("\nGoodbye!")
break
-
+
asyncio.run(run_interactive())
@@ -548,15 +538,27 @@ def channels_status():
# WhatsApp
wa = config.channels.whatsapp
- table.add_row("WhatsApp", "✓" if wa.enabled else "✗", wa.bridge_url)
+ 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)
-
+ table.add_row(
+ "Discord",
+ "✓" if dc.enabled else "✗",
+ dc.gateway_url
+ )
+
# 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)
+ table.add_row(
+ "Telegram",
+ "✓" if tg.enabled else "✗",
+ tg_config
+ )
console.print(table)
@@ -565,57 +567,57 @@ def _get_bridge_dir() -> Path:
"""Get the bridge directory, setting it up if needed."""
import shutil
import subprocess
-
+
# User's bridge location
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)
-
+
# Find source bridge: first check package data, then source dir
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
-
+
source = None
if (pkg_bridge / "package.json").exists():
source = pkg_bridge
elif (src_bridge / "package.json").exists():
source = src_bridge
-
+
if not source:
console.print("[red]Bridge source not found.[/red]")
console.print("Try reinstalling: pip install --force-reinstall nanobot")
raise typer.Exit(1)
-
+
console.print(f"{__logo__} Setting up bridge...")
-
+
# Copy to user directory
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
@@ -623,12 +625,12 @@ def _get_bridge_dir() -> Path:
def channels_login():
"""Link device via QR code."""
import subprocess
-
+
bridge_dir = _get_bridge_dir()
-
+
console.print(f"{__logo__} Starting bridge...")
console.print("Scan the QR code to connect.\n")
-
+
try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True)
except subprocess.CalledProcessError as e:
@@ -652,25 +654,24 @@ def cron_list(
"""List scheduled jobs."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
-
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
jobs = service.list_jobs(include_disabled=all)
-
+
if not jobs:
console.print("No scheduled jobs.")
return
-
+
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")
-
+
import time
-
for job in jobs:
# Format schedule
if job.schedule.kind == "every":
@@ -679,19 +680,17 @@ def cron_list(
sched = job.schedule.expr or ""
else:
sched = "one-time"
-
+
# Format next run
next_run = ""
if job.state.next_run_at_ms:
- next_time = time.strftime(
- "%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)
- )
+ next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
next_run = next_time
-
+
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)
@@ -704,15 +703,13 @@ def cron_add(
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')"
- ),
+ channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"),
):
"""Add a scheduled job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
-
+
# Determine schedule type
if every:
schedule = CronSchedule(kind="every", every_ms=every * 1000)
@@ -720,16 +717,15 @@ def cron_add(
schedule = CronSchedule(kind="cron", expr=cron_expr)
elif at:
import datetime
-
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_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
job = service.add_job(
name=name,
schedule=schedule,
@@ -738,7 +734,7 @@ def cron_add(
to=to,
channel=channel,
)
-
+
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
@@ -749,10 +745,10 @@ def cron_remove(
"""Remove a scheduled job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
-
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
if service.remove_job(job_id):
console.print(f"[green]✓[/green] Removed job {job_id}")
else:
@@ -767,10 +763,10 @@ def cron_enable(
"""Enable or disable a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
-
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
job = service.enable_job(job_id, enabled=not disable)
if job:
status = "disabled" if disable else "enabled"
@@ -787,13 +783,13 @@ def cron_run(
"""Manually run a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
-
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
async def run():
return await service.run_job(job_id, force=force)
-
+
if asyncio.run(run()):
console.print(f"[green]✓[/green] Job executed")
else:
@@ -816,18 +812,14 @@ def status():
console.print(f"{__logo__} nanobot Status\n")
- console.print(
- f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}"
- )
- console.print(
- f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}"
- )
+ 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}")
-
+
# Check API keys from registry
for spec in PROVIDERS:
p = getattr(config.providers, spec.name, None)
@@ -841,9 +833,7 @@ def status():
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]'}"
- )
+ console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
if __name__ == "__main__":
From 8fda0fcab3a62104176b1b75ce3ce458dad28948 Mon Sep 17 00:00:00 2001
From: Chris Alexander <2815297+chris-alexander@users.noreply.github.com>
Date: Sun, 8 Feb 2026 21:51:13 +0000
Subject: [PATCH 032/506] Document agent markdown/log flags and interactive
exit commands
---
README.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/README.md b/README.md
index cb2c64a..5d86820 100644
--- a/README.md
+++ b/README.md
@@ -458,11 +458,15 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
| `nanobot onboard` | Initialize config & workspace |
| `nanobot agent -m "..."` | Chat with the agent |
| `nanobot agent` | Interactive chat mode |
+| `nanobot agent --no-markdown` | Show plain-text replies |
+| `nanobot agent --logs` | Show runtime logs during chat |
| `nanobot gateway` | Start the gateway |
| `nanobot status` | Show status |
| `nanobot channels login` | Link WhatsApp (scan QR) |
| `nanobot channels status` | Show channel status |
+Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
+
Scheduled Tasks (Cron)
From 20ca78c1062ca50fd5e1d3c9acf34ad1c947a1df Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 04:51:58 +0000
Subject: [PATCH 033/506] docs: add Zhipu coding plan apiBase tip
---
README.md | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index cb2c64a..9f1e0fd 100644
--- a/README.md
+++ b/README.md
@@ -378,8 +378,9 @@ Config file: `~/.nanobot/config.json`
### Providers
-> [!NOTE]
-> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
+> [!TIP]
+> - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
+> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
From d47219ef6a094da4aa09318a2051d7262385c48e Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 05:15:26 +0000
Subject: [PATCH 034/506] fix: unify exit cleanup, conditionally show spinner
with --logs flag
---
nanobot/cli/commands.py | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 875eb90..a1f426e 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -144,7 +144,7 @@ def _print_agent_response(response: str, render_markdown: bool) -> None:
console.print(
Panel(
body,
- title=f"{__logo__} Nanobot",
+ title=f"{__logo__} nanobot",
title_align="left",
border_style="cyan",
padding=(0, 1),
@@ -464,10 +464,17 @@ def agent(
restrict_to_workspace=config.tools.restrict_to_workspace,
)
+ # Show spinner when logs are off (no output to miss); skip when logs are on
+ def _thinking_ctx():
+ if logs:
+ from contextlib import nullcontext
+ return nullcontext()
+ return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
+
if message:
# Single message mode
async def run_once():
- with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"):
+ with _thinking_ctx():
response = await agent_loop.process_direct(message, session_id)
_print_agent_response(response, render_markdown=markdown)
@@ -475,7 +482,7 @@ def agent(
else:
# Interactive mode
_enable_line_editing()
- console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n")
+ console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n")
# input() runs in a worker thread that can't be cancelled.
# Without this handler, asyncio.run() would hang waiting for it.
@@ -497,10 +504,12 @@ def agent(
continue
if _is_exit_command(command):
+ _save_history()
+ _restore_terminal()
console.print("\nGoodbye!")
break
- with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"):
+ with _thinking_ctx():
response = await agent_loop.process_direct(user_input, session_id)
_print_agent_response(response, render_markdown=markdown)
except KeyboardInterrupt:
@@ -509,6 +518,8 @@ def agent(
console.print("\nGoodbye!")
break
except EOFError:
+ _save_history()
+ _restore_terminal()
console.print("\nGoodbye!")
break
From d223454a9885986b5c5a89c30fb3941b07457e40 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 06:19:35 +0000
Subject: [PATCH 035/506] fix: cap processed UIDs, move email docs into README,
remove standalone guide
---
EMAIL_ASSISTANT_E2E_GUIDE.md | 164 -----------------------------------
README.md | 57 +++++++++++-
nanobot/channels/email.py | 6 +-
3 files changed, 59 insertions(+), 168 deletions(-)
delete mode 100644 EMAIL_ASSISTANT_E2E_GUIDE.md
diff --git a/EMAIL_ASSISTANT_E2E_GUIDE.md b/EMAIL_ASSISTANT_E2E_GUIDE.md
deleted file mode 100644
index a72a18c..0000000
--- a/EMAIL_ASSISTANT_E2E_GUIDE.md
+++ /dev/null
@@ -1,164 +0,0 @@
-# Nanobot Email Assistant: End-to-End Guide
-
-This guide explains how to run nanobot as a real email assistant with explicit user permission and optional automatic replies.
-
-## 1. What This Feature Does
-
-- Read unread emails via IMAP.
-- Let the agent analyze/respond to email content.
-- Send replies via SMTP.
-- Enforce explicit owner consent before mailbox access.
-- Let you toggle automatic replies on or off.
-
-## 2. Permission Model (Required)
-
-`channels.email.consentGranted` is the hard permission gate.
-
-- `false`: nanobot must not access mailbox content and must not send email.
-- `true`: nanobot may read/send based on other settings.
-
-Only set `consentGranted: true` after the mailbox owner explicitly agrees.
-
-## 3. Auto-Reply Mode
-
-`channels.email.autoReplyEnabled` controls outbound automatic email replies.
-
-- `true`: inbound emails can receive automatic agent replies.
-- `false`: inbound emails can still be read/processed, but automatic replies are skipped.
-
-Use `autoReplyEnabled: false` when you want analysis-only mode.
-
-## 4. Required Account Setup (Gmail Example)
-
-1. Enable 2-Step Verification in Google account security settings.
-2. Create an App Password.
-3. Use this app password for both IMAP and SMTP auth.
-
-Recommended servers:
-- IMAP host/port: `imap.gmail.com:993` (SSL)
-- SMTP host/port: `smtp.gmail.com:587` (STARTTLS)
-
-## 5. Config Example
-
-Edit `~/.nanobot/config.json`:
-
-```json
-{
- "channels": {
- "email": {
- "enabled": true,
- "consentGranted": true,
- "imapHost": "imap.gmail.com",
- "imapPort": 993,
- "imapUsername": "you@gmail.com",
- "imapPassword": "${NANOBOT_EMAIL_IMAP_PASSWORD}",
- "imapMailbox": "INBOX",
- "imapUseSsl": true,
- "smtpHost": "smtp.gmail.com",
- "smtpPort": 587,
- "smtpUsername": "you@gmail.com",
- "smtpPassword": "${NANOBOT_EMAIL_SMTP_PASSWORD}",
- "smtpUseTls": true,
- "smtpUseSsl": false,
- "fromAddress": "you@gmail.com",
- "autoReplyEnabled": true,
- "pollIntervalSeconds": 30,
- "markSeen": true,
- "allowFrom": ["trusted.sender@example.com"]
- }
- }
-}
-```
-
-## 6. Set Secrets via Environment Variables
-
-In the same shell before starting gateway:
-
-```bash
-read -s "NANOBOT_EMAIL_IMAP_PASSWORD?IMAP app password: "
-echo
-read -s "NANOBOT_EMAIL_SMTP_PASSWORD?SMTP app password: "
-echo
-export NANOBOT_EMAIL_IMAP_PASSWORD
-export NANOBOT_EMAIL_SMTP_PASSWORD
-```
-
-If you use one app password for both, enter the same value twice.
-
-## 7. Run and Verify
-
-Start:
-
-```bash
-cd /Users/kaijimima1234/Desktop/nanobot
-PYTHONPATH=/Users/kaijimima1234/Desktop/nanobot .venv/bin/nanobot gateway
-```
-
-Check channel status:
-
-```bash
-PYTHONPATH=/Users/kaijimima1234/Desktop/nanobot .venv/bin/nanobot channels status
-```
-
-Expected behavior:
-- `enabled=true + consentGranted=true + autoReplyEnabled=true`: read + auto reply.
-- `enabled=true + consentGranted=true + autoReplyEnabled=false`: read only, no auto reply.
-- `consentGranted=false`: no read, no send.
-
-## 8. Commands You Can Tell Nanobot
-
-Once gateway is running and email consent is enabled:
-
-1. Summarize yesterday's emails:
-
-```text
-summarize my yesterday email
-```
-
-or
-
-```text
-!email summary yesterday
-```
-
-2. Send an email to a friend:
-
-```text
-!email send friend@example.com | Subject here | Body here
-```
-
-or
-
-```text
-send email to friend@example.com subject: Subject here body: Body here
-```
-
-Notes:
-- Sending command always performs a direct send (manual action by you).
-- If `consentGranted` is `false`, send/read are blocked.
-- If `autoReplyEnabled` is `false`, automatic replies are disabled, but direct send command above still works.
-
-## 9. End-to-End Test Plan
-
-1. Send a test email from an allowed sender to your mailbox.
-2. Confirm nanobot receives and processes it.
-3. If `autoReplyEnabled=true`, confirm a reply is delivered.
-4. Set `autoReplyEnabled=false`, send another test email.
-5. Confirm no auto-reply is sent.
-6. Set `consentGranted=false`, send another test email.
-7. Confirm nanobot does not read/send.
-
-## 10. Security Notes
-
-- Never commit real passwords/tokens into git.
-- Prefer environment variables for secrets.
-- Keep `allowFrom` restricted whenever possible.
-- Rotate app passwords immediately if leaked.
-
-## 11. PR Checklist
-
-- [ ] `consentGranted` gating works for read/send.
-- [ ] `autoReplyEnabled` toggle works as documented.
-- [ ] README updated with new fields.
-- [ ] Tests pass (`pytest`).
-- [ ] No real credentials in tracked files.
diff --git a/README.md b/README.md
index 502a42f..8f7c1a2 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,448 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,479 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
@@ -166,7 +166,7 @@ nanobot agent -m "Hello from my local LLM!"
## 💬 Chat Apps
-Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu — anytime, anywhere.
+Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or Email — anytime, anywhere.
| Channel | Setup |
|---------|-------|
@@ -174,6 +174,8 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu — anytime,
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Medium (scan QR) |
| **Feishu** | Medium (app credentials) |
+| **DingTalk** | Medium (app credentials) |
+| **Email** | Medium (IMAP/SMTP credentials) |
Telegram (Recommended)
@@ -372,6 +374,55 @@ nanobot gateway
+
+Email
+
+Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit consent before accessing mailbox data.
+
+**1. Get credentials (Gmail example)**
+- Enable 2-Step Verification in Google account security
+- Create an [App Password](https://myaccount.google.com/apppasswords)
+- Use this app password for both IMAP and SMTP
+
+**2. Configure**
+
+> [!TIP]
+> Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
+
+```json
+{
+ "channels": {
+ "email": {
+ "enabled": true,
+ "consentGranted": true,
+ "imapHost": "imap.gmail.com",
+ "imapPort": 993,
+ "imapUsername": "you@gmail.com",
+ "imapPassword": "your-app-password",
+ "imapUseSsl": true,
+ "smtpHost": "smtp.gmail.com",
+ "smtpPort": 587,
+ "smtpUsername": "you@gmail.com",
+ "smtpPassword": "your-app-password",
+ "smtpUseTls": true,
+ "fromAddress": "you@gmail.com",
+ "allowFrom": ["trusted@example.com"]
+ }
+ }
+}
+```
+
+> `consentGranted`: Must be `true` to allow mailbox access. Set to `false` to disable reading and sending entirely.
+> `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific sender addresses.
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
## ⚙️ Configuration
Config file: `~/.nanobot/config.json`
@@ -542,7 +593,7 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
- [ ] **Multi-modal** — See and hear (images, voice, video)
- [ ] **Long-term memory** — Never forget important context
- [ ] **Better reasoning** — Multi-step planning and reflection
-- [ ] **More integrations** — Discord, Slack, email, calendar
+- [ ] **More integrations** — Slack, calendar, and more
- [ ] **Self-improvement** — Learn from feedback and mistakes
### Contributors
diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py
index 029c00d..0e47067 100644
--- a/nanobot/channels/email.py
+++ b/nanobot/channels/email.py
@@ -55,7 +55,8 @@ class EmailChannel(BaseChannel):
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()
+ 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."""
@@ -301,6 +302,9 @@ class EmailChannel(BaseChannel):
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:
+ self._processed_uids.clear()
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
From 26c506c413e5ff77e13f49509988a98fa82fdb2a Mon Sep 17 00:00:00 2001
From: JakeRowe19 <117069245+JakeRowe19@users.noreply.github.com>
Date: Mon, 9 Feb 2026 09:49:43 +0300
Subject: [PATCH 036/506] Update README.md
Fixed unclear note for getting Telegram user id.
/issues/74
---
README.md | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 8f7c1a2..335eae0 100644
--- a/README.md
+++ b/README.md
@@ -199,7 +199,9 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or E
}
```
-> Get your user ID from `@userinfobot` on Telegram.
+> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`.
+> Copy this value **without the `@` symbol** and paste it into the config file.
+
**3. Run**
From fc67d11da96d7f4e45df0cbe504116a27f900af2 Mon Sep 17 00:00:00 2001
From: pinhua33
Date: Mon, 9 Feb 2026 15:39:30 +0800
Subject: [PATCH 037/506] feat: add OAuth login command for OpenAI Codex
---
nanobot/cli/commands.py | 859 +++++++++++++++++++++++++++++++++-------
1 file changed, 706 insertions(+), 153 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 40d2ae6..bbdf79c 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -4,9 +4,9 @@ import asyncio
import atexit
import os
import signal
-import sys
from pathlib import Path
import select
+import sys
import typer
from rich.console import Console
@@ -95,148 +95,382 @@ def _enable_line_editing() -> None:
except Exception:
pass
+ history_file = Path.home() / ".nanobot" / "history" / "cli_history"
+ history_file.parent.mkdir(parents=True, exist_ok=True)
+ _HISTORY_FILE = history_file
+
try:
- import readline as _READLINE
- import atexit
-
- # Detect libedit (macOS) vs GNU readline (Linux)
- if hasattr(_READLINE, "__doc__") and _READLINE.__doc__ and "libedit" in _READLINE.__doc__:
- _USING_LIBEDIT = True
-
- hist_file = Path.home() / ".nanobot_history"
- _HISTORY_FILE = hist_file
- try:
- _READLINE.read_history_file(str(hist_file))
- except FileNotFoundError:
- pass
-
- # Enable common readline settings
- _READLINE.parse_and_bind("bind -v" if _USING_LIBEDIT else "set editing-mode vi")
- _READLINE.parse_and_bind("set show-all-if-ambiguous on")
- _READLINE.parse_and_bind("set colored-completion-prefix on")
-
- if not _HISTORY_HOOK_REGISTERED:
- atexit.register(_save_history)
- _HISTORY_HOOK_REGISTERED = True
- except Exception:
+ import readline
+ except ImportError:
return
+ _READLINE = readline
+ _USING_LIBEDIT = "libedit" in (readline.__doc__ or "").lower()
+
+ try:
+ if _USING_LIBEDIT:
+ readline.parse_and_bind("bind ^I rl_complete")
+ else:
+ readline.parse_and_bind("tab: complete")
+ readline.parse_and_bind("set editing-mode emacs")
+ except Exception:
+ pass
+
+ try:
+ readline.read_history_file(str(history_file))
+ except Exception:
+ pass
+
+ if not _HISTORY_HOOK_REGISTERED:
+ atexit.register(_save_history)
+ _HISTORY_HOOK_REGISTERED = True
+
+
+def _prompt_text() -> str:
+ """Build a readline-friendly colored prompt."""
+ if _READLINE is None:
+ return "You: "
+ # libedit on macOS does not honor GNU readline non-printing markers.
+ if _USING_LIBEDIT:
+ return "\033[1;34mYou:\033[0m "
+ return "\001\033[1;34m\002You:\001\033[0m\002 "
+
+
+def _print_agent_response(response: str, render_markdown: bool) -> None:
+ """Render assistant response with consistent terminal styling."""
+ content = response or ""
+ body = Markdown(content) if render_markdown else Text(content)
+ console.print()
+ console.print(
+ Panel(
+ body,
+ title=f"{__logo__} nanobot",
+ title_align="left",
+ border_style="cyan",
+ padding=(0, 1),
+ )
+ )
+ console.print()
+
+
+def _is_exit_command(command: str) -> bool:
+ """Return True when input should end interactive chat."""
+ return command.lower() in EXIT_COMMANDS
+
async def _read_interactive_input_async() -> str:
- """Async wrapper around synchronous input() (runs in thread pool)."""
- loop = asyncio.get_running_loop()
- return await loop.run_in_executor(None, lambda: input(f"{__logo__} "))
-
-
-def _is_exit_command(text: str) -> bool:
- return text.strip().lower() in EXIT_COMMANDS
-
-
-# ---------------------------------------------------------------------------
-# OAuth and Authentication helpers
-# ---------------------------------------------------------------------------
-
-def _handle_oauth_login(provider: str) -> None:
- """Handle OAuth login flow for supported providers."""
- from nanobot.providers.registry import get_oauth_handler
-
- oauth_handler = get_oauth_handler(provider)
- if oauth_handler is None:
- console.print(f"[red]OAuth is not supported for provider: {provider}[/red]")
- console.print("[yellow]Supported OAuth providers: github-copilot[/yellow]")
- raise typer.Exit(1)
-
+ """Read user input with arrow keys and history (runs input() in a thread)."""
try:
- result = oauth_handler.authenticate()
- if result.success:
- console.print(f"[green]✓ {result.message}[/green]")
- if result.token_path:
- console.print(f"[dim]Token saved to: {result.token_path}[/dim]")
- else:
- console.print(f"[red]✗ {result.message}[/red]")
- raise typer.Exit(1)
- except Exception as e:
- console.print(f"[red]OAuth authentication failed: {e}[/red]")
- raise typer.Exit(1)
+ return await asyncio.to_thread(input, _prompt_text())
+ except EOFError as exc:
+ raise KeyboardInterrupt from exc
-# ---------------------------------------------------------------------------
-# @agent decorator and public API helpers
-# ---------------------------------------------------------------------------
-
-_agent_registry: dict[str, callable] = {}
+def version_callback(value: bool):
+ if value:
+ console.print(f"{__logo__} nanobot v{__version__}")
+ raise typer.Exit()
-def _get_agent(name: str | None = None) -> callable | None:
- """Retrieve a registered agent function by name."""
- if name is None:
- # Return the first registered agent if no name specified
- return next(iter(_agent_registry.values())) if _agent_registry else None
- return _agent_registry.get(name)
+@app.callback()
+def main(
+ version: bool = typer.Option(
+ None, "--version", "-v", callback=version_callback, is_eager=True
+ ),
+):
+ """nanobot - Personal AI Assistant."""
+ pass
-def agent(name: str | None = None, model: str | None = None, prompt: str | None = None):
- """Decorator to register an agent function.
+# ============================================================================
+# Onboard / Setup
+# ============================================================================
+
+
+@app.command()
+def onboard():
+ """Initialize nanobot configuration and workspace."""
+ from nanobot.config.loader import get_config_path, save_config
+ from nanobot.config.schema import Config
+ from nanobot.utils.helpers import get_workspace_path
- Args:
- name: Optional name for the agent (defaults to function name)
- model: Optional model override (e.g., "gpt-4o", "claude-3-opus")
- prompt: Optional system prompt for the agent
- """
- def decorator(func):
- agent_name = name or func.__name__
- _agent_registry[agent_name] = func
- func._agent_config = {"model": model, "prompt": prompt}
- return func
- return decorator
+ config_path = get_config_path()
+
+ if config_path.exists():
+ console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
+ if not typer.confirm("Overwrite?"):
+ raise typer.Exit()
+
+ # Create default config
+ config = Config()
+ save_config(config)
+ console.print(f"[green]✓[/green] Created config at {config_path}")
+
+ # Create workspace
+ workspace = get_workspace_path()
+ console.print(f"[green]✓[/green] Created workspace at {workspace}")
+
+ # Create default bootstrap files
+ _create_workspace_templates(workspace)
+
+ console.print(f"\n{__logo__} nanobot is ready!")
+ console.print("\nNext steps:")
+ console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
+ console.print(" Get one at: https://openrouter.ai/keys")
+ console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]")
+ console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
-# ---------------------------------------------------------------------------
-# Built-in CLI commands
-# ---------------------------------------------------------------------------
-@app.command()
-def login(
- provider: str = typer.Argument(..., help="Provider to authenticate with (e.g., 'github-copilot')"),
-):
- """Authenticate with an OAuth provider."""
- _handle_oauth_login(provider)
+
+def _create_workspace_templates(workspace: Path):
+ """Create default workspace template files."""
+ templates = {
+ "AGENTS.md": """# Agent Instructions
+
+You are a helpful AI assistant. Be concise, accurate, and friendly.
+
+## Guidelines
+
+- Always explain what you're doing before taking actions
+- Ask for clarification when the request is ambiguous
+- Use tools to help accomplish tasks
+- Remember important information in your memory files
+""",
+ "SOUL.md": """# Soul
+
+I am nanobot, a lightweight 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
+""",
+ "USER.md": """# User
+
+Information about the user goes here.
+
+## Preferences
+
+- Communication style: (casual/formal)
+- Timezone: (your timezone)
+- Language: (your preferred language)
+""",
+ }
+
+ for filename, content in templates.items():
+ file_path = workspace / filename
+ if not file_path.exists():
+ file_path.write_text(content)
+ console.print(f" [dim]Created {filename}[/dim]")
+
+ # Create memory directory and MEMORY.md
+ memory_dir = workspace / "memory"
+ memory_dir.mkdir(exist_ok=True)
+ memory_file = memory_dir / "MEMORY.md"
+ if not memory_file.exists():
+ memory_file.write_text("""# 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)
+
+## Important Notes
+
+(Things to remember)
+""")
+ console.print(" [dim]Created memory/MEMORY.md[/dim]")
+
+
+def _make_provider(config):
+ """Create LiteLLMProvider from config. Exits if no API key found."""
+ from nanobot.providers.litellm_provider import LiteLLMProvider
+ p = config.get_provider()
+ model = config.agents.defaults.model
+ if not (p and p.api_key) and not model.startswith("bedrock/"):
+ 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(),
+ default_model=model,
+ extra_headers=p.extra_headers if p else None,
+ provider_name=config.get_provider_name(),
+ )
+
+
+# ============================================================================
+# Gateway / Server
+# ============================================================================
@app.command()
-def version():
- """Show version information."""
- console.print(f"{__logo__} nanobot {__version__}")
-
-
-@app.command(name="agent")
-def run_agent(
- name: str | None = typer.Argument(None, help="Name of the agent to run"),
- message: str = typer.Option(None, "--message", "-m", help="Single message to send to the agent"),
- model: str = typer.Option(None, "--model", help="Override the model for this run"),
- markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render response as markdown"),
- session_id: str = typer.Option("cli", "--session", "-s", help="Session ID for this conversation"),
+def gateway(
+ port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
- """Run an interactive AI agent session."""
- import asyncio
+ """Start the nanobot gateway."""
+ from nanobot.config.loader import load_config, get_data_dir
+ from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
+ from nanobot.channels.manager import ChannelManager
+ from nanobot.session.manager import SessionManager
+ from nanobot.cron.service import CronService
+ from nanobot.cron.types import CronJob
+ from nanobot.heartbeat.service import HeartbeatService
- # Get the agent function
- agent_func = _get_agent(name)
- if agent_func is None:
- if name:
- console.print(f"[red]Agent '{name}' not found[/red]")
- else:
- console.print("[yellow]No agents registered. Use @agent decorator to register agents.[/yellow]")
- raise typer.Exit(1)
+ if verbose:
+ import logging
+ logging.basicConfig(level=logging.DEBUG)
- # Initialize agent loop
- agent_config = getattr(agent_func, '_agent_config', {})
- agent_model = model or agent_config.get('model')
- agent_prompt = agent_config.get('prompt')
+ console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
- agent_loop = AgentLoop(model=agent_model, system_prompt=agent_prompt)
+ config = load_config()
+ bus = MessageBus()
+ provider = _make_provider(config)
+ session_manager = SessionManager(config.workspace_path)
+ # Create cron service first (callback set after agent creation)
+ cron_store_path = get_data_dir() / "cron" / "jobs.json"
+ cron = CronService(cron_store_path)
+
+ # Create agent with cron service
+ 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,
+ cron_service=cron,
+ restrict_to_workspace=config.tools.restrict_to_workspace,
+ session_manager=session_manager,
+ )
+
+ # Set cron callback (needs agent)
+ async def on_cron_job(job: CronJob) -> str | None:
+ """Execute a cron job through the agent."""
+ response = await agent.process_direct(
+ job.payload.message,
+ session_key=f"cron:{job.id}",
+ channel=job.payload.channel or "cli",
+ chat_id=job.payload.to or "direct",
+ )
+ if job.payload.deliver and job.payload.to:
+ from nanobot.bus.events import OutboundMessage
+ await bus.publish_outbound(OutboundMessage(
+ channel=job.payload.channel or "cli",
+ chat_id=job.payload.to,
+ content=response or ""
+ ))
+ return response
+ cron.on_job = on_cron_job
+
+ # Create heartbeat service
+ async def on_heartbeat(prompt: str) -> str:
+ """Execute heartbeat through the agent."""
+ return await agent.process_direct(prompt, session_key="heartbeat")
+
+ heartbeat = HeartbeatService(
+ workspace=config.workspace_path,
+ on_heartbeat=on_heartbeat,
+ interval_s=30 * 60, # 30 minutes
+ enabled=True
+ )
+
+ # Create channel manager
+ channels = ChannelManager(config, bus, session_manager=session_manager)
+
+ 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():
+ try:
+ await cron.start()
+ await heartbeat.start()
+ await asyncio.gather(
+ agent.run(),
+ channels.start_all(),
+ )
+ except KeyboardInterrupt:
+ console.print("\nShutting down...")
+ heartbeat.stop()
+ cron.stop()
+ agent.stop()
+ await channels.stop_all()
+
+ asyncio.run(run())
+
+
+
+
+# ============================================================================
+# 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:default", "--session", "-s", help="Session ID"),
+ markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
+ logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
+):
+ """Interact with the agent directly."""
+ from nanobot.config.loader import load_config
+ from nanobot.bus.queue import MessageBus
+ from nanobot.agent.loop import AgentLoop
+ from loguru import logger
+
+ config = load_config()
+
+ bus = MessageBus()
+ provider = _make_provider(config)
+
+ if logs:
+ logger.enable("nanobot")
+ else:
+ logger.disable("nanobot")
+
+ agent_loop = AgentLoop(
+ bus=bus,
+ provider=provider,
+ workspace=config.workspace_path,
+ brave_api_key=config.tools.web.search.api_key or None,
+ exec_config=config.tools.exec,
+ restrict_to_workspace=config.tools.restrict_to_workspace,
+ )
+
+ # Show spinner when logs are off (no output to miss); skip when logs are on
+ def _thinking_ctx():
+ if logs:
+ from contextlib import nullcontext
+ return nullcontext()
+ return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
+
if message:
# Single message mode
async def run_once():
@@ -283,55 +517,374 @@ def run_agent(
_restore_terminal()
console.print("\nGoodbye!")
break
+ except EOFError:
+ _save_history()
+ _restore_terminal()
+ console.print("\nGoodbye!")
+ break
asyncio.run(run_interactive())
-def _thinking_ctx():
- """Context manager for showing thinking indicator."""
- from rich.live import Live
- from rich.spinner import Spinner
+# ============================================================================
+# Channel Commands
+# ============================================================================
+
+
+channels_app = typer.Typer(help="Manage channels")
+app.add_typer(channels_app, name="channels")
+
+
+@channels_app.command("status")
+def channels_status():
+ """Show channel 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")
+
+ # 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
+ )
- class ThinkingSpinner:
- def __enter__(self):
- self.live = Live(Spinner("dots", text="Thinking..."), console=console, refresh_per_second=10)
- self.live.start()
- return self
+ # 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
+ )
+
+ console.print(table)
+
+
+def _get_bridge_dir() -> Path:
+ """Get the bridge directory, setting it up if needed."""
+ import shutil
+ import subprocess
+
+ # User's bridge location
+ 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)
+
+ # Find source bridge: first check package data, then source dir
+ pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
+ src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
+
+ source = None
+ if (pkg_bridge / "package.json").exists():
+ source = pkg_bridge
+ elif (src_bridge / "package.json").exists():
+ source = src_bridge
+
+ if not source:
+ console.print("[red]Bridge source not found.[/red]")
+ console.print("Try reinstalling: pip install --force-reinstall nanobot")
+ raise typer.Exit(1)
+
+ console.print(f"{__logo__} Setting up bridge...")
+
+ # Copy to user directory
+ 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)
- def __exit__(self, exc_type, exc_val, exc_tb):
- self.live.stop()
- return False
+ 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 ThinkingSpinner()
+ return user_bridge
-def _print_agent_response(response: str, render_markdown: bool = True):
- """Print agent response with optional markdown rendering."""
- if render_markdown:
- console.print(Markdown(response))
+@channels_app.command("login")
+def channels_login():
+ """Link device via QR code."""
+ import subprocess
+
+ bridge_dir = _get_bridge_dir()
+
+ console.print(f"{__logo__} Starting bridge...")
+ console.print("Scan the QR code to connect.\n")
+
+ try:
+ subprocess.run(["npm", "start"], cwd=bridge_dir, check=True)
+ 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.command("list")
+def cron_list(
+ all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"),
+):
+ """List scheduled jobs."""
+ from nanobot.config.loader import get_data_dir
+ from nanobot.cron.service import CronService
+
+ store_path = get_data_dir() / "cron" / "jobs.json"
+ service = CronService(store_path)
+
+ jobs = service.list_jobs(include_disabled=all)
+
+ if not jobs:
+ console.print("No scheduled jobs.")
+ return
+
+ 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")
+
+ import time
+ 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 = job.schedule.expr or ""
+ else:
+ sched = "one-time"
+
+ # Format next run
+ next_run = ""
+ if job.state.next_run_at_ms:
+ next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
+ next_run = next_time
+
+ 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 for agent"),
+ 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 * * *')"),
+ 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')"),
+):
+ """Add a scheduled job."""
+ from nanobot.config.loader import get_data_dir
+ from nanobot.cron.service import CronService
+ from nanobot.cron.types import CronSchedule
+
+ # Determine schedule type
+ if every:
+ schedule = CronSchedule(kind="every", every_ms=every * 1000)
+ elif cron_expr:
+ schedule = CronSchedule(kind="cron", expr=cron_expr)
+ elif at:
+ import datetime
+ dt = datetime.datetime.fromisoformat(at)
+ schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
else:
- console.print(response)
- console.print()
+ console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
+ raise typer.Exit(1)
+
+ store_path = get_data_dir() / "cron" / "jobs.json"
+ service = CronService(store_path)
+
+ job = service.add_job(
+ name=name,
+ schedule=schedule,
+ message=message,
+ deliver=deliver,
+ to=to,
+ channel=channel,
+ )
+
+ 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"),
+):
+ """Remove a scheduled job."""
+ from nanobot.config.loader import get_data_dir
+ from nanobot.cron.service import CronService
+
+ store_path = get_data_dir() / "cron" / "jobs.json"
+ 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"),
+):
+ """Enable or disable a job."""
+ from nanobot.config.loader import get_data_dir
+ from nanobot.cron.service import CronService
+
+ store_path = get_data_dir() / "cron" / "jobs.json"
+ 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"),
+):
+ """Manually run a job."""
+ from nanobot.config.loader import get_data_dir
+ from nanobot.cron.service import CronService
+
+ store_path = get_data_dir() / "cron" / "jobs.json"
+ service = CronService(store_path)
+
+ async def run():
+ return await service.run_job(job_id, force=force)
+
+ if asyncio.run(run()):
+ console.print(f"[green]✓[/green] Job executed")
+ else:
+ console.print(f"[red]Failed to run job {job_id}[/red]")
+
+
+# ============================================================================
+# Status Commands
+# ============================================================================
@app.command()
-def setup():
- """Interactive setup wizard for nanobot."""
- console.print(Panel.fit(
- f"{__logo__} Welcome to nanobot setup!\n\n"
- "This wizard will help you configure nanobot.",
- title="Setup",
- border_style="green"
- ))
+def status():
+ """Show nanobot status."""
+ from nanobot.config.loader import load_config, get_config_path
+
+ config_path = get_config_path()
+ config = load_config()
+ workspace = config.workspace_path
+
+ console.print(f"{__logo__} nanobot Status\n")
+
+ console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}")
+ console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}")
+
+ if config_path.exists():
+ from nanobot.providers.registry import PROVIDERS
+
+ console.print(f"Model: {config.agents.defaults.model}")
+
+ # Check API keys from registry
+ for spec in PROVIDERS:
+ p = getattr(config.providers, spec.name, None)
+ if p is None:
+ continue
+ if 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
+# ============================================================================
+
+
+@app.command()
+def login(
+ provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex')"),
+):
+ """Authenticate with an OAuth provider."""
+ console.print(f"{__logo__} OAuth Login - {provider}\n")
- # TODO: Implement setup wizard
- console.print("[yellow]Setup wizard coming soon![/yellow]")
-
-
-def main():
- """Main entry point for the CLI."""
- app()
+ if provider == "openai-codex":
+ try:
+ from oauth_cli_kit import get_token as get_codex_token
+
+ console.print("[cyan]Starting OpenAI Codex authentication...[/cyan]")
+ console.print("A browser window will open for you to authenticate.\n")
+
+ token = get_codex_token()
+
+ if token and token.access:
+ console.print(f"[green]✓ Successfully authenticated with OpenAI Codex![/green]")
+ console.print(f"[dim]Account ID: {token.account_id}[/dim]")
+ else:
+ console.print("[red]✗ Authentication failed[/red]")
+ raise typer.Exit(1)
+ except ImportError:
+ console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
+ raise typer.Exit(1)
+ except Exception as e:
+ console.print(f"[red]Authentication error: {e}[/red]")
+ raise typer.Exit(1)
+ else:
+ console.print(f"[red]Unknown OAuth provider: {provider}[/red]")
+ console.print("[yellow]Supported providers: openai-codex[/yellow]")
+ raise typer.Exit(1)
if __name__ == "__main__":
- main()
+ app()
From 34dc933fce092d8bd8de8df2c543276d7691156f Mon Sep 17 00:00:00 2001
From: yinwm
Date: Mon, 9 Feb 2026 15:47:55 +0800
Subject: [PATCH 038/506] feat: add QQ channel integration with botpy SDK
Add official QQ platform support using botpy SDK with WebSocket connection.
Features:
- C2C (private message) support via QQ Open Platform
- WebSocket-based bot connection (no public IP required)
- Message deduplication with efficient deque-based LRU cache
- User whitelist support via allow_from configuration
- Clean async architecture using single event loop
Changes:
- Add QQChannel implementation in nanobot/channels/qq.py
- Add QQConfig schema with appId and secret fields
- Register QQ channel in ChannelManager
- Update README with QQ setup instructions
- Add qq-botpy dependency to pyproject.toml
- Add botpy.log to .gitignore
Setup:
1. Get AppID and Secret from q.qq.com
2. Configure in ~/.nanobot/config.json:
{
"channels": {
"qq": {
"enabled": true,
"appId": "YOUR_APP_ID",
"secret": "YOUR_APP_SECRET",
"allowFrom": []
}
}
}
3. Run: nanobot gateway
Note: Group chat support will be added in future updates.
Co-Authored-By: Claude Sonnet 4.5
---
.gitignore | 3 +-
README.md | 42 ++++++-
nanobot/channels/manager.py | 12 ++
nanobot/channels/qq.py | 211 ++++++++++++++++++++++++++++++++++++
nanobot/config/schema.py | 9 ++
pyproject.toml | 1 +
6 files changed, 276 insertions(+), 2 deletions(-)
create mode 100644 nanobot/channels/qq.py
diff --git a/.gitignore b/.gitignore
index 55338f7..4e58574 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,5 @@ docs/
__pycache__/
poetry.lock
.pytest_cache/
-tests/
\ No newline at end of file
+tests/
+botpy.log
\ No newline at end of file
diff --git a/README.md b/README.md
index 8f7c1a2..4acaca8 100644
--- a/README.md
+++ b/README.md
@@ -166,7 +166,7 @@ nanobot agent -m "Hello from my local LLM!"
## 💬 Chat Apps
-Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or Email — anytime, anywhere.
+Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Email, or QQ — anytime, anywhere.
| Channel | Setup |
|---------|-------|
@@ -176,6 +176,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or E
| **Feishu** | Medium (app credentials) |
| **DingTalk** | Medium (app credentials) |
| **Email** | Medium (IMAP/SMTP credentials) |
+| **QQ** | Easy (app credentials) |
Telegram (Recommended)
@@ -335,6 +336,45 @@ nanobot gateway
+
+QQ (QQ私聊)
+
+Uses **botpy SDK** with WebSocket — no public IP required.
+
+**1. Create a QQ bot**
+- Visit [QQ Open Platform](https://q.qq.com)
+- Create a new bot application
+- Get **AppID** and **Secret** from "Developer Settings"
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "appId": "YOUR_APP_ID",
+ "secret": "YOUR_APP_SECRET",
+ "allowFrom": []
+ }
+ }
+}
+```
+
+> `allowFrom`: Leave empty for public access, or add user openids to restrict access.
+> Example: `"allowFrom": ["user_openid_1", "user_openid_2"]`
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+> [!TIP]
+> QQ bot currently supports **private messages only**. Group chat support coming soon!
+
+
+
DingTalk (钉钉)
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 26fa9f3..a7b1ed5 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -106,6 +106,18 @@ class ChannelManager:
logger.info("Email channel enabled")
except ImportError as e:
logger.warning(f"Email channel not available: {e}")
+
+ # QQ channel
+ 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(f"QQ channel not available: {e}")
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""Start a channel and log any exceptions."""
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
new file mode 100644
index 0000000..98ca883
--- /dev/null
+++ b/nanobot/channels/qq.py
@@ -0,0 +1,211 @@
+"""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 InboundMessage, 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 parse_chat_id(chat_id: str) -> tuple[str, str]:
+ """Parse chat_id into (channel, user_id).
+
+ Args:
+ chat_id: Format "channel:user_id", e.g. "qq:openid_xxx"
+
+ Returns:
+ Tuple of (channel, user_id)
+ """
+ if ":" not in chat_id:
+ raise ValueError(f"Invalid chat_id format: {chat_id}")
+ channel, user_id = chat_id.split(":", 1)
+ return channel, user_id
+
+
+class QQChannel(BaseChannel):
+ """
+ QQ channel using botpy SDK with WebSocket connection.
+
+ Uses botpy SDK to connect to QQ Open Platform (q.qq.com).
+
+ Requires:
+ - App ID and Secret from q.qq.com
+ - Robot capability enabled
+ """
+
+ 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_message_ids: deque = deque(maxlen=1000)
+ self._bot_task: asyncio.Task | None = None
+
+ async def start(self) -> None:
+ """Start the QQ bot."""
+ if not QQ_AVAILABLE:
+ logger.error("QQ SDK 未安装。请运行:pip install qq-botpy")
+ return
+
+ if not self.config.app_id or not self.config.secret:
+ logger.error("QQ app_id 和 secret 未配置")
+ return
+
+ self._running = True
+
+ # Create bot client with C2C intents
+ intents = botpy.Intents.all()
+ logger.info(f"QQ Intents 配置值: {intents.value}")
+
+ # Create custom bot class with message handlers
+ class QQBot(botpy.Client):
+ def __init__(self, channel):
+ super().__init__(intents=intents)
+ self.channel = channel
+
+ async def on_ready(self):
+ """Called when bot is ready."""
+ logger.info(f"QQ bot ready: {self.robot.name}")
+
+ async def on_c2c_message_create(self, message: "C2CMessage"):
+ """Handle C2C (Client to Client) messages - private chat."""
+ await self.channel._on_message(message, "c2c")
+
+ async def on_direct_message_create(self, message):
+ """Handle direct messages - alternative event name."""
+ await self.channel._on_message(message, "direct")
+
+ # TODO: Group message support - implement in future PRD
+ # async def on_group_at_message_create(self, message):
+ # """Handle group @ messages."""
+ # pass
+
+ self._client = QQBot(self)
+
+ # Start bot - use create_task to run concurrently
+ self._bot_task = asyncio.create_task(
+ self._run_bot_with_retry(self.config.app_id, self.config.secret)
+ )
+
+ logger.info("QQ bot started with C2C (private message) support")
+
+ async def _run_bot_with_retry(self, app_id: str, secret: str) -> None:
+ """Run bot with error handling."""
+ try:
+ await self._client.start(appid=app_id, secret=secret)
+ except Exception as e:
+ logger.error(
+ f"QQ 鉴权失败,请检查 AppID 和 Secret 是否正确。"
+ f"访问 q.qq.com 获取凭证。错误: {e}"
+ )
+ self._running = False
+
+ async def stop(self) -> None:
+ """Stop the QQ bot."""
+ self._running = False
+
+ if self._bot_task:
+ self._bot_task.cancel()
+ try:
+ await self._bot_task
+ except asyncio.CancelledError:
+ 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:
+ # Parse chat_id format: qq:{user_id}
+ channel, user_id = parse_chat_id(msg.chat_id)
+
+ if channel != "qq":
+ logger.warning(f"Invalid channel in chat_id: {msg.chat_id}")
+ return
+
+ # Send private message using botpy API
+ await self._client.api.post_c2c_message(
+ openid=user_id,
+ msg_type=0,
+ content=msg.content,
+ )
+ logger.debug(f"QQ message sent to {msg.chat_id}")
+
+ except ValueError as e:
+ logger.error(f"Invalid chat_id format: {e}")
+ except Exception as e:
+ logger.error(f"Error sending QQ message: {e}")
+
+ async def _on_message(self, data: "C2CMessage", msg_type: str) -> None:
+ """Handle incoming message from QQ."""
+ try:
+ # Message deduplication using deque with maxlen
+ message_id = data.id
+ if message_id in self._processed_message_ids:
+ logger.debug(f"Duplicate message {message_id}, skipping")
+ return
+
+ self._processed_message_ids.append(message_id)
+
+ # Extract user ID and chat ID from message
+ author = data.author
+ # Try different possible field names for user ID
+ user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown'))
+ user_name = getattr(author, 'username', None) or 'unknown'
+
+ # For C2C messages, chat_id is the user's ID
+ chat_id = f"qq:{user_id}"
+
+ # Check allow_from list (if configured)
+ if self.config.allow_from and user_id not in self.config.allow_from:
+ logger.info(f"User {user_id} not in allow_from list")
+ return
+
+ # Get message content
+ content = data.content or ""
+
+ if not content:
+ logger.debug(f"Empty message from {user_id}, skipping")
+ return
+
+ # Publish to message bus
+ msg = InboundMessage(
+ channel=self.name,
+ sender_id=user_id,
+ chat_id=chat_id,
+ content=content,
+ metadata={
+ "message_id": message_id,
+ "user_name": user_name,
+ "msg_type": msg_type,
+ },
+ )
+ await self.bus.publish_inbound(msg)
+
+ logger.info(f"Received QQ message from {user_id} ({msg_type}): {content[:50]}")
+
+ except Exception as e:
+ logger.error(f"Error handling QQ message: {e}")
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index aa9729b..f31d279 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -77,6 +77,14 @@ class EmailConfig(BaseModel):
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
+class QQConfig(BaseModel):
+ """QQ channel configuration using 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(BaseModel):
"""Configuration for chat channels."""
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
@@ -85,6 +93,7 @@ class ChannelsConfig(BaseModel):
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig)
email: EmailConfig = Field(default_factory=EmailConfig)
+ qq: QQConfig = Field(default_factory=QQConfig)
class AgentDefaults(BaseModel):
diff --git a/pyproject.toml b/pyproject.toml
index 6fda084..21b50f0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,6 +32,7 @@ dependencies = [
"python-telegram-bot[socks]>=21.0",
"lark-oapi>=1.0.0",
"socksio>=1.0.0",
+ "qq-botpy>=1.0.0",
]
[project.optional-dependencies]
From 51f97efcb89fab2b3288883df0c5284a3f3ac171 Mon Sep 17 00:00:00 2001
From: pinhua33
Date: Mon, 9 Feb 2026 16:04:04 +0800
Subject: [PATCH 039/506] refactor: simplify Codex URL handling by removing
unnecessary function
---
nanobot/providers/openai_codex_provider.py | 13 ++-----------
1 file changed, 2 insertions(+), 11 deletions(-)
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index f92db09..9c98db5 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -12,7 +12,7 @@ import httpx
from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
-DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
+DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_ORIGINATOR = "nanobot"
@@ -53,7 +53,7 @@ class OpenAICodexProvider(LLMProvider):
if tools:
body["tools"] = _convert_tools(tools)
- url = _resolve_codex_url(DEFAULT_CODEX_BASE_URL)
+ url = DEFAULT_CODEX_URL
try:
try:
@@ -84,15 +84,6 @@ def _strip_model_prefix(model: str) -> str:
return model
-def _resolve_codex_url(base_url: str) -> str:
- raw = base_url.rstrip("/")
- if raw.endswith("/codex/responses"):
- return raw
- if raw.endswith("/codex"):
- return f"{raw}/responses"
- return f"{raw}/codex/responses"
-
-
def _build_headers(account_id: str, token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
From 20b8a2fc58dd4550c487d9b6f88e38b57a55b4bf Mon Sep 17 00:00:00 2001
From: tjb-tech
Date: Mon, 9 Feb 2026 08:46:47 +0000
Subject: [PATCH 040/506] feat(channels): add Moltchat websocket channel with
polling fallback
---
README.md | 49 +-
nanobot/channels/__init__.py | 3 +-
nanobot/channels/manager.py | 12 +
nanobot/channels/moltchat.py | 1227 ++++++++++++++++++++++++++++++++
nanobot/cli/commands.py | 18 +
nanobot/config/schema.py | 37 +
pyproject.toml | 2 +
tests/test_moltchat_channel.py | 115 +++
8 files changed, 1459 insertions(+), 4 deletions(-)
create mode 100644 nanobot/channels/moltchat.py
create mode 100644 tests/test_moltchat_channel.py
diff --git a/README.md b/README.md
index 8a15892..74c24d9 100644
--- a/README.md
+++ b/README.md
@@ -164,7 +164,7 @@ nanobot agent -m "Hello from my local LLM!"
## 💬 Chat Apps
-Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu — anytime, anywhere.
+Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, or Moltchat — anytime, anywhere.
| Channel | Setup |
|---------|-------|
@@ -172,6 +172,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu — anytime,
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Medium (scan QR) |
| **Feishu** | Medium (app credentials) |
+| **Moltchat** | Medium (claw token + websocket) |
Telegram (Recommended)
@@ -205,6 +206,48 @@ nanobot gateway
+
+Moltchat (Claw IM)
+
+Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
+
+**1. Prepare credentials**
+- `clawToken`: Claw API token
+- `agentUserId`: your bot user id
+- Optional: `sessions`/`panels` with `["*"]` for auto-discovery
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "moltchat": {
+ "enabled": true,
+ "baseUrl": "https://mochat.io",
+ "socketUrl": "https://mochat.io",
+ "socketPath": "/socket.io",
+ "clawToken": "claw_xxx",
+ "agentUserId": "69820107a785110aea8b1069",
+ "sessions": ["*"],
+ "panels": ["*"],
+ "replyDelayMode": "non-mention",
+ "replyDelayMs": 120000
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+> [!TIP]
+> Keep `clawToken` private. It should only be sent in `X-Claw-Token` header to your Moltchat API endpoint.
+
+
+
Discord
@@ -413,7 +456,7 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot onboard
# Edit config on host to add API keys
vim ~/.nanobot/config.json
-# Run gateway (connects to Telegram/WhatsApp)
+# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Moltchat)
docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot gateway
# Or run a single command
@@ -433,7 +476,7 @@ nanobot/
│ ├── subagent.py # Background task execution
│ └── tools/ # Built-in tools (incl. spawn)
├── skills/ # 🎯 Bundled skills (github, weather, tmux...)
-├── channels/ # 📱 WhatsApp integration
+├── channels/ # 📱 Chat channel integrations
├── bus/ # 🚌 Message routing
├── cron/ # ⏰ Scheduled tasks
├── heartbeat/ # 💓 Proactive wake-up
diff --git a/nanobot/channels/__init__.py b/nanobot/channels/__init__.py
index 588169d..4d77063 100644
--- a/nanobot/channels/__init__.py
+++ b/nanobot/channels/__init__.py
@@ -2,5 +2,6 @@
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
+from nanobot.channels.moltchat import MoltchatChannel
-__all__ = ["BaseChannel", "ChannelManager"]
+__all__ = ["BaseChannel", "ChannelManager", "MoltchatChannel"]
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 64ced48..11690ef 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -77,6 +77,18 @@ class ChannelManager:
logger.info("Feishu channel enabled")
except ImportError as e:
logger.warning(f"Feishu channel not available: {e}")
+
+ # Moltchat channel
+ if self.config.channels.moltchat.enabled:
+ try:
+ from nanobot.channels.moltchat import MoltchatChannel
+
+ self.channels["moltchat"] = MoltchatChannel(
+ self.config.channels.moltchat, self.bus
+ )
+ logger.info("Moltchat channel enabled")
+ except ImportError as e:
+ logger.warning(f"Moltchat channel not available: {e}")
async def start_all(self) -> None:
"""Start WhatsApp channel and the outbound dispatcher."""
diff --git a/nanobot/channels/moltchat.py b/nanobot/channels/moltchat.py
new file mode 100644
index 0000000..cc590d4
--- /dev/null
+++ b/nanobot/channels/moltchat.py
@@ -0,0 +1,1227 @@
+"""Moltchat 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 MoltchatConfig
+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
+
+
+@dataclass
+class MoltchatBufferedEntry:
+ """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[MoltchatBufferedEntry] = field(default_factory=list)
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
+ timer: asyncio.Task | None = None
+
+
+@dataclass
+class MoltchatTarget:
+ """Outbound target resolution result."""
+
+ id: str
+ is_panel: bool
+
+
+def normalize_moltchat_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_moltchat_target(raw: str) -> MoltchatTarget:
+ """Resolve id and target kind from user-provided target string."""
+ trimmed = (raw or "").strip()
+ if not trimmed:
+ return MoltchatTarget(id="", is_panel=False)
+
+ lowered = trimmed.lower()
+ cleaned = trimmed
+ forced_panel = False
+
+ prefixes = ["moltchat:", "mochat:", "group:", "channel:", "panel:"]
+ for prefix in prefixes:
+ if lowered.startswith(prefix):
+ cleaned = trimmed[len(prefix) :].strip()
+ if prefix in {"group:", "channel:", "panel:"}:
+ forced_panel = True
+ break
+
+ if not cleaned:
+ return MoltchatTarget(id="", is_panel=False)
+
+ return MoltchatTarget(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):
+ text = item.strip()
+ if text:
+ ids.append(text)
+ continue
+
+ if not isinstance(item, dict):
+ continue
+
+ 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 field in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"):
+ ids = extract_mention_ids(meta.get(field))
+ if agent_user_id and agent_user_id in ids:
+ 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: MoltchatConfig,
+ session_id: str,
+ group_id: str,
+) -> bool:
+ """Resolve mention requirement for group/panel conversations."""
+ groups = config.groups or {}
+ if group_id and group_id in groups:
+ return bool(groups[group_id].require_mention)
+ if session_id in groups:
+ return bool(groups[session_id].require_mention)
+ if "*" in groups:
+ return bool(groups["*"].require_mention)
+ return bool(config.mention.require_in_groups)
+
+
+def build_buffered_body(entries: list[MoltchatBufferedEntry], 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:
+ body = entry.raw_body
+ if not body:
+ continue
+ if is_group:
+ label = entry.sender_name.strip() or entry.sender_username.strip() or entry.author
+ if label:
+ lines.append(f"{label}: {body}")
+ continue
+ lines.append(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
+
+
+class MoltchatChannel(BaseChannel):
+ """Moltchat channel using socket.io with fallback polling workers."""
+
+ name = "moltchat"
+
+ def __init__(self, config: MoltchatConfig, bus: MessageBus):
+ super().__init__(config, bus)
+ self.config: MoltchatConfig = config
+ self._http: httpx.AsyncClient | None = None
+ self._socket: Any = None
+ self._ws_connected = False
+ self._ws_ready = False
+
+ self._state_dir = get_data_path() / "moltchat"
+ 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 = False
+ 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] = {}
+
+ async def start(self) -> None:
+ """Start Moltchat channel workers and websocket connection."""
+ if not self.config.claw_token:
+ logger.error("Moltchat 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)
+
+ websocket_started = await self._start_socket_client()
+ if not websocket_started:
+ 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 = False
+ 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("Moltchat claw_token missing, skip send")
+ return
+
+ content_parts = [msg.content.strip()] if msg.content and msg.content.strip() else []
+ if msg.media:
+ content_parts.extend([m for m in msg.media if isinstance(m, str) and m.strip()])
+ content = "\n".join(content_parts).strip()
+ if not content:
+ return
+
+ target = resolve_moltchat_target(msg.chat_id)
+ if not target.id:
+ logger.warning("Moltchat outbound target is empty")
+ return
+
+ is_panel = target.is_panel or target.id in self._panel_set
+ if target.id.startswith("session_"):
+ is_panel = False
+
+ try:
+ if is_panel:
+ await self._send_panel_message(
+ panel_id=target.id,
+ content=content,
+ reply_to=msg.reply_to,
+ group_id=self._read_group_id(msg.metadata),
+ )
+ else:
+ await self._send_session_message(
+ session_id=target.id,
+ content=content,
+ reply_to=msg.reply_to,
+ )
+ except Exception as e:
+ logger.error(f"Failed to send Moltchat message: {e}")
+
+ 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 session_id in sessions:
+ if session_id not in self._session_cursor:
+ self._cold_sessions.add(session_id)
+
+ def _normalize_id_list(self, values: list[str]) -> tuple[list[str], bool]:
+ cleaned = [str(v).strip() for v in values if str(v).strip()]
+ has_wildcard = "*" in cleaned
+ ids = sorted({v for v in cleaned if v != "*"})
+ return ids, has_wildcard
+
+ async def _start_socket_client(self) -> bool:
+ if not SOCKETIO_AVAILABLE:
+ logger.warning("python-socketio not installed, Moltchat using polling fallback")
+ return False
+
+ serializer = "default"
+ if not self.config.socket_disable_msgpack:
+ if MSGPACK_AVAILABLE:
+ serializer = "msgpack"
+ else:
+ logger.warning(
+ "msgpack is not installed but socket_disable_msgpack=false; "
+ "trying JSON serializer"
+ )
+
+ reconnect_attempts = None
+ if self.config.max_retry_attempts > 0:
+ reconnect_attempts = self.config.max_retry_attempts
+
+ client = socketio.AsyncClient(
+ reconnection=True,
+ reconnection_attempts=reconnect_attempts,
+ 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 = True
+ self._ws_ready = False
+ logger.info("Moltchat websocket connected")
+
+ subscribed = await self._subscribe_all()
+ self._ws_ready = subscribed
+ if subscribed:
+ await self._stop_fallback_workers()
+ else:
+ await self._ensure_fallback_workers()
+
+ @client.event
+ async def disconnect() -> None:
+ if not self._running:
+ return
+ self._ws_connected = False
+ self._ws_ready = False
+ logger.warning("Moltchat websocket disconnected")
+ await self._ensure_fallback_workers()
+
+ @client.event
+ async def connect_error(data: Any) -> None:
+ message = str(data)
+ logger.error(f"Moltchat websocket connect error: {message}")
+
+ @client.on("claw.session.events")
+ async def on_session_events(payload: dict[str, Any]) -> None:
+ await self._handle_watch_payload(payload, target_kind="session")
+
+ @client.on("claw.panel.events")
+ async def on_panel_events(payload: dict[str, Any]) -> None:
+ await self._handle_watch_payload(payload, target_kind="panel")
+
+ for event_name in (
+ "notify:chat.inbox.append",
+ "notify:chat.message.add",
+ "notify:chat.message.update",
+ "notify:chat.message.recall",
+ "notify:chat.message.delete",
+ ):
+ client.on(event_name, self._build_notify_handler(event_name))
+
+ socket_url = (self.config.socket_url or self.config.base_url).strip().rstrip("/")
+ socket_path = (self.config.socket_path or "/socket.io").strip()
+ if socket_path.startswith("/"):
+ socket_path = socket_path[1:]
+
+ 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(f"Failed to connect Moltchat 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)
+ return
+
+ if event_name.startswith("notify:chat.message."):
+ await self._handle_notify_chat_message(payload)
+
+ return handler
+
+ async def _subscribe_all(self) -> bool:
+ sessions_ok = await self._subscribe_sessions(sorted(self._session_set))
+ panels_ok = await self._subscribe_panels(sorted(self._panel_set))
+
+ if self._auto_discover_sessions or self._auto_discover_panels:
+ await self._refresh_targets(subscribe_new=True)
+
+ return sessions_ok and panels_ok
+
+ async def _subscribe_sessions(self, session_ids: list[str]) -> bool:
+ if not session_ids:
+ return True
+
+ for session_id in session_ids:
+ if session_id not in self._session_cursor:
+ self._cold_sessions.add(session_id)
+
+ 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(f"Moltchat subscribeSessions failed: {ack.get('message', 'unknown error')}")
+ return False
+
+ data = ack.get("data")
+ items: list[dict[str, Any]] = []
+ if isinstance(data, list):
+ items = [item for item in data if isinstance(item, dict)]
+ elif isinstance(data, dict):
+ sessions = data.get("sessions")
+ if isinstance(sessions, list):
+ items = [item for item in sessions if isinstance(item, dict)]
+ elif "sessionId" in data:
+ items = [data]
+
+ for payload in items:
+ await self._handle_watch_payload(payload, target_kind="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(f"Moltchat 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)}
+
+ if isinstance(raw, dict):
+ return raw
+
+ return {"result": True, "data": raw}
+
+ 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(f"Moltchat 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=subscribe_new)
+
+ if self._auto_discover_panels:
+ await self._refresh_panels(subscribe_new=subscribe_new)
+
+ async def _refresh_sessions_directory(self, subscribe_new: bool) -> None:
+ try:
+ response = await self._list_sessions()
+ except Exception as e:
+ logger.warning(f"Moltchat listSessions failed: {e}")
+ return
+
+ sessions = response.get("sessions")
+ if not isinstance(sessions, list):
+ return
+
+ new_sessions: list[str] = []
+ for session in sessions:
+ if not isinstance(session, dict):
+ continue
+
+ session_id = str(session.get("sessionId") or "").strip()
+ if not session_id:
+ continue
+
+ if session_id not in self._session_set:
+ self._session_set.add(session_id)
+ new_sessions.append(session_id)
+ if session_id not in self._session_cursor:
+ self._cold_sessions.add(session_id)
+
+ converse_id = str(session.get("converseId") or "").strip()
+ if converse_id:
+ self._session_by_converse[converse_id] = session_id
+
+ if not new_sessions:
+ return
+
+ if self._ws_ready and subscribe_new:
+ await self._subscribe_sessions(new_sessions)
+
+ if self._fallback_mode:
+ await self._ensure_fallback_workers()
+
+ async def _refresh_panels(self, subscribe_new: bool) -> None:
+ try:
+ response = await self._get_workspace_group()
+ except Exception as e:
+ logger.warning(f"Moltchat getWorkspaceGroup failed: {e}")
+ return
+
+ raw_panels = response.get("panels")
+ if not isinstance(raw_panels, list):
+ return
+
+ new_panels: list[str] = []
+ for panel in raw_panels:
+ if not isinstance(panel, dict):
+ continue
+
+ panel_type = panel.get("type")
+ if isinstance(panel_type, int) and panel_type != 0:
+ continue
+
+ panel_id = str(panel.get("id") or panel.get("_id") or "").strip()
+ if not panel_id:
+ continue
+
+ if panel_id not in self._panel_set:
+ self._panel_set.add(panel_id)
+ new_panels.append(panel_id)
+
+ if not new_panels:
+ return
+
+ if self._ws_ready and subscribe_new:
+ await self._subscribe_panels(new_panels)
+
+ if self._fallback_mode:
+ await self._ensure_fallback_workers()
+
+ async def _ensure_fallback_workers(self) -> None:
+ if not self._running:
+ return
+
+ self._fallback_mode = True
+
+ for session_id in sorted(self._session_set):
+ task = self._session_fallback_tasks.get(session_id)
+ if task and not task.done():
+ continue
+ self._session_fallback_tasks[session_id] = asyncio.create_task(
+ self._session_watch_worker(session_id)
+ )
+
+ for panel_id in sorted(self._panel_set):
+ task = self._panel_fallback_tasks.get(panel_id)
+ if task and not task.done():
+ continue
+ self._panel_fallback_tasks[panel_id] = asyncio.create_task(
+ self._panel_poll_worker(panel_id)
+ )
+
+ async def _stop_fallback_workers(self) -> None:
+ self._fallback_mode = False
+
+ tasks = [
+ *self._session_fallback_tasks.values(),
+ *self._panel_fallback_tasks.values(),
+ ]
+ for task in tasks:
+ task.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._watch_session(
+ session_id=session_id,
+ cursor=self._session_cursor.get(session_id, 0),
+ timeout_ms=self.config.watch_timeout_ms,
+ limit=self.config.watch_limit,
+ )
+ await self._handle_watch_payload(payload, target_kind="session")
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.warning(f"Moltchat 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:
+ response = await self._list_panel_messages(
+ panel_id=panel_id,
+ limit=min(100, max(1, self.config.watch_limit)),
+ )
+
+ raw_messages = response.get("messages")
+ if isinstance(raw_messages, list):
+ for message in reversed(raw_messages):
+ if not isinstance(message, dict):
+ continue
+
+ synthetic_event = {
+ "type": "message.add",
+ "timestamp": message.get("createdAt") or datetime.utcnow().isoformat(),
+ "payload": {
+ "messageId": str(message.get("messageId") or ""),
+ "author": str(message.get("author") or ""),
+ "authorInfo": message.get("authorInfo") if isinstance(message.get("authorInfo"), dict) else {},
+ "content": message.get("content"),
+ "meta": message.get("meta") if isinstance(message.get("meta"), dict) else {},
+ "groupId": str(response.get("groupId") or ""),
+ "converseId": panel_id,
+ },
+ }
+ await self._process_inbound_event(
+ target_id=panel_id,
+ event=synthetic_event,
+ target_kind="panel",
+ )
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.warning(f"Moltchat panel polling error ({panel_id}): {e}")
+
+ await asyncio.sleep(sleep_s)
+
+ async def _handle_watch_payload(
+ self,
+ payload: dict[str, Any],
+ target_kind: str,
+ ) -> None:
+ if not isinstance(payload, dict):
+ return
+
+ target_id = str(payload.get("sessionId") or "").strip()
+ if not target_id:
+ return
+
+ lock = self._target_locks.setdefault(f"{target_kind}:{target_id}", asyncio.Lock())
+ async with lock:
+ previous_cursor = self._session_cursor.get(target_id, 0) if target_kind == "session" else 0
+ payload_cursor = payload.get("cursor")
+ if (
+ target_kind == "session"
+ and isinstance(payload_cursor, int)
+ and payload_cursor >= 0
+ ):
+ self._mark_session_cursor(target_id, payload_cursor)
+
+ 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, previous_cursor)
+ ):
+ self._mark_session_cursor(target_id, seq)
+
+ if event.get("type") != "message.add":
+ continue
+
+ await self._process_inbound_event(
+ target_id=target_id,
+ event=event,
+ target_kind=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(payload.get("author") or "").strip()
+ if not author:
+ return
+
+ if self.config.agent_user_id and author == self.config.agent_user_id:
+ return
+
+ if not self.is_allowed(author):
+ return
+
+ message_id = str(payload.get("messageId") or "").strip()
+ seen_key = f"{target_kind}:{target_id}"
+ if message_id and self._remember_message_id(seen_key, message_id):
+ return
+
+ raw_body = normalize_moltchat_content(payload.get("content"))
+ if not raw_body:
+ raw_body = "[empty message]"
+
+ author_info = payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {}
+ sender_name = str(author_info.get("nickname") or author_info.get("email") or "").strip()
+ sender_username = str(author_info.get("agentId") or "").strip()
+
+ group_id = str(payload.get("groupId") or "").strip()
+ 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 = MoltchatBufferedEntry(
+ 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 = f"{target_kind}:{target_id}"
+ if was_mentioned:
+ await self._flush_delayed_entries(
+ key=delay_key,
+ target_id=target_id,
+ target_kind=target_kind,
+ reason="mention",
+ entry=entry,
+ )
+ else:
+ await self._enqueue_delayed_entry(
+ key=delay_key,
+ target_id=target_id,
+ target_kind=target_kind,
+ entry=entry,
+ )
+ return
+
+ await self._dispatch_entries(
+ target_id=target_id,
+ target_kind=target_kind,
+ entries=[entry],
+ was_mentioned=was_mentioned,
+ )
+
+ 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:
+ removed = seen_queue.popleft()
+ seen_set.discard(removed)
+
+ return False
+
+ async def _enqueue_delayed_entry(
+ self,
+ key: str,
+ target_id: str,
+ target_kind: str,
+ entry: MoltchatBufferedEntry,
+ ) -> 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=key,
+ target_id=target_id,
+ target_kind=target_kind,
+ reason="timer",
+ entry=None,
+ )
+
+ async def _flush_delayed_entries(
+ self,
+ key: str,
+ target_id: str,
+ target_kind: str,
+ reason: str,
+ entry: MoltchatBufferedEntry | 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
+ elif state.timer is current:
+ state.timer = None
+
+ entries = state.entries[:]
+ state.entries.clear()
+
+ if not entries:
+ return
+
+ await self._dispatch_entries(
+ target_id=target_id,
+ target_kind=target_kind,
+ entries=entries,
+ was_mentioned=(reason == "mention"),
+ )
+
+ async def _dispatch_entries(
+ self,
+ target_id: str,
+ target_kind: str,
+ entries: list[MoltchatBufferedEntry],
+ was_mentioned: bool,
+ ) -> None:
+ if not entries:
+ return
+
+ is_group = bool(entries[-1].group_id)
+ body = build_buffered_body(entries, is_group)
+ if not body:
+ body = "[empty message]"
+
+ last = entries[-1]
+ 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),
+ }
+
+ await self._handle_message(
+ sender_id=last.author,
+ chat_id=target_id,
+ content=body,
+ metadata=metadata,
+ )
+
+ async def _cancel_delay_timers(self) -> None:
+ for state in self._delay_states.values():
+ if state.timer:
+ state.timer.cancel()
+ state.timer = None
+ self._delay_states.clear()
+
+ async def _handle_notify_chat_message(self, payload: Any) -> None:
+ if not isinstance(payload, dict):
+ return
+
+ group_id = str(payload.get("groupId") or "").strip()
+ panel_id = str(payload.get("converseId") or payload.get("panelId") or "").strip()
+ if not group_id or not panel_id:
+ return
+
+ if self._panel_set and panel_id not in self._panel_set:
+ return
+
+ synthetic_event = {
+ "type": "message.add",
+ "timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(),
+ "payload": {
+ "messageId": str(payload.get("_id") or payload.get("messageId") or ""),
+ "author": str(payload.get("author") or ""),
+ "authorInfo": payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {},
+ "content": payload.get("content"),
+ "meta": payload.get("meta") if isinstance(payload.get("meta"), dict) else {},
+ "groupId": group_id,
+ "converseId": panel_id,
+ },
+ }
+ await self._process_inbound_event(
+ target_id=panel_id,
+ event=synthetic_event,
+ target_kind="panel",
+ )
+
+ async def _handle_notify_inbox_append(self, payload: Any) -> None:
+ if not isinstance(payload, dict):
+ return
+
+ if payload.get("type") != "message":
+ return
+
+ detail = payload.get("payload")
+ if not isinstance(detail, dict):
+ return
+
+ group_id = str(detail.get("groupId") or "").strip()
+ if group_id:
+ return
+
+ converse_id = str(detail.get("converseId") or "").strip()
+ if not converse_id:
+ return
+
+ session_id = self._session_by_converse.get(converse_id)
+ if not session_id:
+ await self._refresh_sessions_directory(subscribe_new=self._ws_ready)
+ session_id = self._session_by_converse.get(converse_id)
+ if not session_id:
+ return
+
+ message_id = str(detail.get("messageId") or payload.get("_id") or "").strip()
+ author = str(detail.get("messageAuthor") or "").strip()
+ content = str(detail.get("messagePlainContent") or detail.get("messageSnippet") or "").strip()
+
+ synthetic_event = {
+ "type": "message.add",
+ "timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(),
+ "payload": {
+ "messageId": message_id,
+ "author": author,
+ "content": content,
+ "meta": {
+ "source": "notify:chat.inbox.append",
+ "converseId": converse_id,
+ },
+ "converseId": converse_id,
+ },
+ }
+
+ await self._process_inbound_event(
+ target_id=session_id,
+ event=synthetic_event,
+ target_kind="session",
+ )
+
+ def _mark_session_cursor(self, session_id: str, cursor: int) -> None:
+ if cursor < 0:
+ return
+
+ previous = self._session_cursor.get(session_id, 0)
+ if cursor < previous:
+ return
+
+ self._session_cursor[session_id] = cursor
+ self._schedule_cursor_save()
+
+ def _schedule_cursor_save(self) -> None:
+ if self._cursor_save_task and not self._cursor_save_task.done():
+ return
+
+ 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(f"Failed to read Moltchat cursor file: {e}")
+ return
+
+ cursors = data.get("cursors") if isinstance(data, dict) else None
+ if not isinstance(cursors, dict):
+ return
+
+ for session_id, cursor in cursors.items():
+ if isinstance(session_id, str) and isinstance(cursor, int) and cursor >= 0:
+ self._session_cursor[session_id] = cursor
+
+ async def _save_session_cursors(self) -> None:
+ payload = {
+ "schemaVersion": 1,
+ "updatedAt": datetime.utcnow().isoformat(),
+ "cursors": self._session_cursor,
+ }
+
+ try:
+ self._state_dir.mkdir(parents=True, exist_ok=True)
+ self._cursor_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", "utf-8")
+ except Exception as e:
+ logger.warning(f"Failed to save Moltchat cursor file: {e}")
+
+ def _base_url(self) -> str:
+ return self.config.base_url.strip().rstrip("/")
+
+ async def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
+ if not self._http:
+ raise RuntimeError("Moltchat HTTP client not initialized")
+
+ url = f"{self._base_url()}{path}"
+ response = await self._http.post(
+ url,
+ headers={
+ "Content-Type": "application/json",
+ "X-Claw-Token": self.config.claw_token,
+ },
+ json=payload,
+ )
+
+ text = response.text
+ if not response.is_success:
+ raise RuntimeError(f"Moltchat HTTP {response.status_code}: {text[:200]}")
+
+ parsed: Any
+ try:
+ parsed = response.json()
+ except Exception:
+ parsed = text
+
+ if isinstance(parsed, dict) and isinstance(parsed.get("code"), int):
+ if parsed["code"] != 200:
+ message = str(parsed.get("message") or parsed.get("name") or "request failed")
+ raise RuntimeError(f"Moltchat API error: {message} (code={parsed['code']})")
+ data = parsed.get("data")
+ return data if isinstance(data, dict) else {}
+
+ if isinstance(parsed, dict):
+ return parsed
+
+ return {}
+
+ async def _watch_session(
+ self,
+ session_id: str,
+ cursor: int,
+ timeout_ms: int,
+ limit: int,
+ ) -> dict[str, Any]:
+ return await self._post_json(
+ "/api/claw/sessions/watch",
+ {
+ "sessionId": session_id,
+ "cursor": cursor,
+ "timeoutMs": timeout_ms,
+ "limit": limit,
+ },
+ )
+
+ async def _send_session_message(
+ self,
+ session_id: str,
+ content: str,
+ reply_to: str | None,
+ ) -> dict[str, Any]:
+ payload = {
+ "sessionId": session_id,
+ "content": content,
+ }
+ if reply_to:
+ payload["replyTo"] = reply_to
+ return await self._post_json("/api/claw/sessions/send", payload)
+
+ async def _send_panel_message(
+ self,
+ panel_id: str,
+ content: str,
+ reply_to: str | None,
+ group_id: str | None,
+ ) -> dict[str, Any]:
+ payload = {
+ "panelId": panel_id,
+ "content": content,
+ }
+ if reply_to:
+ payload["replyTo"] = reply_to
+ if group_id:
+ payload["groupId"] = group_id
+ return await self._post_json("/api/claw/groups/panels/send", payload)
+
+ async def _list_sessions(self) -> dict[str, Any]:
+ return await self._post_json("/api/claw/sessions/list", {})
+
+ async def _get_workspace_group(self) -> dict[str, Any]:
+ return await self._post_json("/api/claw/groups/get", {})
+
+ async def _list_panel_messages(self, panel_id: str, limit: int) -> dict[str, Any]:
+ return await self._post_json(
+ "/api/claw/groups/panels/messages",
+ {
+ "panelId": panel_id,
+ "limit": limit,
+ },
+ )
+
+ def _read_group_id(self, metadata: dict[str, Any]) -> str | None:
+ if not isinstance(metadata, dict):
+ return None
+ value = metadata.get("group_id") or metadata.get("groupId")
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return None
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 19e62e9..2039f82 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -366,6 +366,24 @@ def channels_status():
"✓" 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
+ )
+
+ # Moltchat
+ mc = config.channels.moltchat
+ mc_base = mc.base_url or "[dim]not configured[/dim]"
+ table.add_row(
+ "Moltchat",
+ "✓" if mc.enabled else "✗",
+ mc_base
+ )
# Telegram
tg = config.channels.telegram
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 7724288..4df4251 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -39,12 +39,49 @@ class DiscordConfig(BaseModel):
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
+class MoltchatMentionConfig(BaseModel):
+ """Moltchat mention behavior configuration."""
+ require_in_groups: bool = False
+
+
+class MoltchatGroupRule(BaseModel):
+ """Moltchat per-group mention requirement."""
+ require_mention: bool = False
+
+
+class MoltchatConfig(BaseModel):
+ """Moltchat channel configuration."""
+ enabled: bool = False
+ base_url: str = "http://localhost:11000"
+ 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: MoltchatMentionConfig = Field(default_factory=MoltchatMentionConfig)
+ groups: dict[str, MoltchatGroupRule] = Field(default_factory=dict)
+ reply_delay_mode: str = "non-mention" # off | non-mention
+ reply_delay_ms: int = 120000
+
+
class ChannelsConfig(BaseModel):
"""Configuration for chat channels."""
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
discord: DiscordConfig = Field(default_factory=DiscordConfig)
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
+ moltchat: MoltchatConfig = Field(default_factory=MoltchatConfig)
class AgentDefaults(BaseModel):
diff --git a/pyproject.toml b/pyproject.toml
index 2a952a1..81d38b7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,8 @@ dependencies = [
"croniter>=2.0.0",
"python-telegram-bot>=21.0",
"lark-oapi>=1.0.0",
+ "python-socketio>=5.11.0",
+ "msgpack>=1.0.8",
]
[project.optional-dependencies]
diff --git a/tests/test_moltchat_channel.py b/tests/test_moltchat_channel.py
new file mode 100644
index 0000000..1f65a68
--- /dev/null
+++ b/tests/test_moltchat_channel.py
@@ -0,0 +1,115 @@
+import pytest
+
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.moltchat import (
+ MoltchatBufferedEntry,
+ MoltchatChannel,
+ build_buffered_body,
+ resolve_moltchat_target,
+ resolve_require_mention,
+ resolve_was_mentioned,
+)
+from nanobot.config.schema import MoltchatConfig, MoltchatGroupRule, MoltchatMentionConfig
+
+
+def test_resolve_moltchat_target_prefixes() -> None:
+ t = resolve_moltchat_target("panel:abc")
+ assert t.id == "abc"
+ assert t.is_panel is True
+
+ t = resolve_moltchat_target("session_123")
+ assert t.id == "session_123"
+ assert t.is_panel is False
+
+ t = resolve_moltchat_target("mochat:session_456")
+ assert t.id == "session_456"
+ assert t.is_panel is False
+
+
+def test_resolve_was_mentioned_from_meta_and_text() -> None:
+ payload = {
+ "content": "hello",
+ "meta": {
+ "mentionIds": ["bot-1"],
+ },
+ }
+ assert resolve_was_mentioned(payload, "bot-1") is True
+
+ payload = {"content": "ping <@bot-2>", "meta": {}}
+ assert resolve_was_mentioned(payload, "bot-2") is True
+
+
+def test_resolve_require_mention_priority() -> None:
+ cfg = MoltchatConfig(
+ groups={
+ "*": MoltchatGroupRule(require_mention=False),
+ "group-a": MoltchatGroupRule(require_mention=True),
+ },
+ mention=MoltchatMentionConfig(require_in_groups=False),
+ )
+
+ assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-a") is True
+ assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-b") is False
+
+
+@pytest.mark.asyncio
+async def test_delay_buffer_flushes_on_mention() -> None:
+ bus = MessageBus()
+ cfg = MoltchatConfig(
+ enabled=True,
+ claw_token="token",
+ agent_user_id="bot",
+ reply_delay_mode="non-mention",
+ reply_delay_ms=60_000,
+ )
+ channel = MoltchatChannel(cfg, bus)
+
+ first = {
+ "type": "message.add",
+ "timestamp": "2026-02-07T10:00:00Z",
+ "payload": {
+ "messageId": "m1",
+ "author": "user1",
+ "content": "first",
+ "groupId": "group-1",
+ "meta": {},
+ },
+ }
+ second = {
+ "type": "message.add",
+ "timestamp": "2026-02-07T10:00:01Z",
+ "payload": {
+ "messageId": "m2",
+ "author": "user2",
+ "content": "hello <@bot>",
+ "groupId": "group-1",
+ "meta": {},
+ },
+ }
+
+ await channel._process_inbound_event(target_id="panel-1", event=first, target_kind="panel")
+ assert bus.inbound_size == 0
+
+ await channel._process_inbound_event(target_id="panel-1", event=second, target_kind="panel")
+ assert bus.inbound_size == 1
+
+ msg = await bus.consume_inbound()
+ assert msg.channel == "moltchat"
+ assert msg.chat_id == "panel-1"
+ assert "user1: first" in msg.content
+ assert "user2: hello <@bot>" in msg.content
+ assert msg.metadata.get("buffered_count") == 2
+
+ await channel._cancel_delay_timers()
+
+
+def test_build_buffered_body_group_labels() -> None:
+ body = build_buffered_body(
+ entries=[
+ MoltchatBufferedEntry(raw_body="a", author="u1", sender_name="Alice"),
+ MoltchatBufferedEntry(raw_body="b", author="u2", sender_username="bot"),
+ ],
+ is_group=True,
+ )
+ assert "Alice: a" in body
+ assert "bot: b" in body
From 377922591788ae7c1fb84e83b9ba1a3e29fd893f Mon Sep 17 00:00:00 2001
From: tjb-tech
Date: Mon, 9 Feb 2026 08:50:17 +0000
Subject: [PATCH 041/506] refactor(channels): rename moltchat integration to
mochat
---
README.md | 12 +--
nanobot/channels/__init__.py | 4 +-
nanobot/channels/manager.py | 14 +--
nanobot/channels/{moltchat.py => mochat.py} | 94 +++++++++----------
nanobot/cli/commands.py | 6 +-
nanobot/config/schema.py | 18 ++--
...chat_channel.py => test_mochat_channel.py} | 36 +++----
7 files changed, 92 insertions(+), 92 deletions(-)
rename nanobot/channels/{moltchat.py => mochat.py} (92%)
rename tests/{test_moltchat_channel.py => test_mochat_channel.py} (73%)
diff --git a/README.md b/README.md
index 74c24d9..55dc7fa 100644
--- a/README.md
+++ b/README.md
@@ -164,7 +164,7 @@ nanobot agent -m "Hello from my local LLM!"
## 💬 Chat Apps
-Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, or Moltchat — anytime, anywhere.
+Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, or Mochat — anytime, anywhere.
| Channel | Setup |
|---------|-------|
@@ -172,7 +172,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, or Moltchat
| **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Medium (scan QR) |
| **Feishu** | Medium (app credentials) |
-| **Moltchat** | Medium (claw token + websocket) |
+| **Mochat** | Medium (claw token + websocket) |
Telegram (Recommended)
@@ -207,7 +207,7 @@ nanobot gateway
-Moltchat (Claw IM)
+Mochat (Claw IM)
Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
@@ -221,7 +221,7 @@ Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
```json
{
"channels": {
- "moltchat": {
+ "mochat": {
"enabled": true,
"baseUrl": "https://mochat.io",
"socketUrl": "https://mochat.io",
@@ -244,7 +244,7 @@ nanobot gateway
```
> [!TIP]
-> Keep `clawToken` private. It should only be sent in `X-Claw-Token` header to your Moltchat API endpoint.
+> Keep `clawToken` private. It should only be sent in `X-Claw-Token` header to your Mochat API endpoint.
@@ -456,7 +456,7 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot onboard
# Edit config on host to add API keys
vim ~/.nanobot/config.json
-# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Moltchat)
+# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat)
docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot gateway
# Or run a single command
diff --git a/nanobot/channels/__init__.py b/nanobot/channels/__init__.py
index 4d77063..034d401 100644
--- a/nanobot/channels/__init__.py
+++ b/nanobot/channels/__init__.py
@@ -2,6 +2,6 @@
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
-from nanobot.channels.moltchat import MoltchatChannel
+from nanobot.channels.mochat import MochatChannel
-__all__ = ["BaseChannel", "ChannelManager", "MoltchatChannel"]
+__all__ = ["BaseChannel", "ChannelManager", "MochatChannel"]
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 11690ef..64214ce 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -78,17 +78,17 @@ class ChannelManager:
except ImportError as e:
logger.warning(f"Feishu channel not available: {e}")
- # Moltchat channel
- if self.config.channels.moltchat.enabled:
+ # Mochat channel
+ if self.config.channels.mochat.enabled:
try:
- from nanobot.channels.moltchat import MoltchatChannel
+ from nanobot.channels.mochat import MochatChannel
- self.channels["moltchat"] = MoltchatChannel(
- self.config.channels.moltchat, self.bus
+ self.channels["mochat"] = MochatChannel(
+ self.config.channels.mochat, self.bus
)
- logger.info("Moltchat channel enabled")
+ logger.info("Mochat channel enabled")
except ImportError as e:
- logger.warning(f"Moltchat channel not available: {e}")
+ logger.warning(f"Mochat channel not available: {e}")
async def start_all(self) -> None:
"""Start WhatsApp channel and the outbound dispatcher."""
diff --git a/nanobot/channels/moltchat.py b/nanobot/channels/mochat.py
similarity index 92%
rename from nanobot/channels/moltchat.py
rename to nanobot/channels/mochat.py
index cc590d4..6569cdd 100644
--- a/nanobot/channels/moltchat.py
+++ b/nanobot/channels/mochat.py
@@ -1,4 +1,4 @@
-"""Moltchat channel implementation using Socket.IO with HTTP polling fallback."""
+"""Mochat channel implementation using Socket.IO with HTTP polling fallback."""
from __future__ import annotations
@@ -15,7 +15,7 @@ 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 MoltchatConfig
+from nanobot.config.schema import MochatConfig
from nanobot.utils.helpers import get_data_path
try:
@@ -39,7 +39,7 @@ CURSOR_SAVE_DEBOUNCE_S = 0.5
@dataclass
-class MoltchatBufferedEntry:
+class MochatBufferedEntry:
"""Buffered inbound entry for delayed dispatch."""
raw_body: str
@@ -55,20 +55,20 @@ class MoltchatBufferedEntry:
class DelayState:
"""Per-target delayed message state."""
- entries: list[MoltchatBufferedEntry] = field(default_factory=list)
+ entries: list[MochatBufferedEntry] = field(default_factory=list)
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
timer: asyncio.Task | None = None
@dataclass
-class MoltchatTarget:
+class MochatTarget:
"""Outbound target resolution result."""
id: str
is_panel: bool
-def normalize_moltchat_content(content: Any) -> str:
+def normalize_mochat_content(content: Any) -> str:
"""Normalize content payload to text."""
if isinstance(content, str):
return content.strip()
@@ -80,17 +80,17 @@ def normalize_moltchat_content(content: Any) -> str:
return str(content)
-def resolve_moltchat_target(raw: str) -> MoltchatTarget:
+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 MoltchatTarget(id="", is_panel=False)
+ return MochatTarget(id="", is_panel=False)
lowered = trimmed.lower()
cleaned = trimmed
forced_panel = False
- prefixes = ["moltchat:", "mochat:", "group:", "channel:", "panel:"]
+ prefixes = ["mochat:", "group:", "channel:", "panel:"]
for prefix in prefixes:
if lowered.startswith(prefix):
cleaned = trimmed[len(prefix) :].strip()
@@ -99,9 +99,9 @@ def resolve_moltchat_target(raw: str) -> MoltchatTarget:
break
if not cleaned:
- return MoltchatTarget(id="", is_panel=False)
+ return MochatTarget(id="", is_panel=False)
- return MoltchatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_"))
+ return MochatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_"))
def extract_mention_ids(value: Any) -> list[str]:
@@ -152,7 +152,7 @@ def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool:
def resolve_require_mention(
- config: MoltchatConfig,
+ config: MochatConfig,
session_id: str,
group_id: str,
) -> bool:
@@ -167,7 +167,7 @@ def resolve_require_mention(
return bool(config.mention.require_in_groups)
-def build_buffered_body(entries: list[MoltchatBufferedEntry], is_group: bool) -> str:
+def build_buffered_body(entries: list[MochatBufferedEntry], is_group: bool) -> str:
"""Build text body from one or more buffered entries."""
if not entries:
return ""
@@ -200,20 +200,20 @@ def parse_timestamp(value: Any) -> int | None:
return None
-class MoltchatChannel(BaseChannel):
- """Moltchat channel using socket.io with fallback polling workers."""
+class MochatChannel(BaseChannel):
+ """Mochat channel using socket.io with fallback polling workers."""
- name = "moltchat"
+ name = "mochat"
- def __init__(self, config: MoltchatConfig, bus: MessageBus):
+ def __init__(self, config: MochatConfig, bus: MessageBus):
super().__init__(config, bus)
- self.config: MoltchatConfig = config
+ self.config: MochatConfig = config
self._http: httpx.AsyncClient | None = None
self._socket: Any = None
self._ws_connected = False
self._ws_ready = False
- self._state_dir = get_data_path() / "moltchat"
+ 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
@@ -239,9 +239,9 @@ class MoltchatChannel(BaseChannel):
self._target_locks: dict[str, asyncio.Lock] = {}
async def start(self) -> None:
- """Start Moltchat channel workers and websocket connection."""
+ """Start Mochat channel workers and websocket connection."""
if not self.config.claw_token:
- logger.error("Moltchat claw_token not configured")
+ logger.error("Mochat claw_token not configured")
return
self._running = True
@@ -296,7 +296,7 @@ class MoltchatChannel(BaseChannel):
async def send(self, msg: OutboundMessage) -> None:
"""Send outbound message to session or panel."""
if not self.config.claw_token:
- logger.warning("Moltchat claw_token missing, skip send")
+ logger.warning("Mochat claw_token missing, skip send")
return
content_parts = [msg.content.strip()] if msg.content and msg.content.strip() else []
@@ -306,9 +306,9 @@ class MoltchatChannel(BaseChannel):
if not content:
return
- target = resolve_moltchat_target(msg.chat_id)
+ target = resolve_mochat_target(msg.chat_id)
if not target.id:
- logger.warning("Moltchat outbound target is empty")
+ logger.warning("Mochat outbound target is empty")
return
is_panel = target.is_panel or target.id in self._panel_set
@@ -330,7 +330,7 @@ class MoltchatChannel(BaseChannel):
reply_to=msg.reply_to,
)
except Exception as e:
- logger.error(f"Failed to send Moltchat message: {e}")
+ logger.error(f"Failed to send Mochat message: {e}")
def _seed_targets_from_config(self) -> None:
sessions, self._auto_discover_sessions = self._normalize_id_list(self.config.sessions)
@@ -351,7 +351,7 @@ class MoltchatChannel(BaseChannel):
async def _start_socket_client(self) -> bool:
if not SOCKETIO_AVAILABLE:
- logger.warning("python-socketio not installed, Moltchat using polling fallback")
+ logger.warning("python-socketio not installed, Mochat using polling fallback")
return False
serializer = "default"
@@ -385,7 +385,7 @@ class MoltchatChannel(BaseChannel):
async def connect() -> None:
self._ws_connected = True
self._ws_ready = False
- logger.info("Moltchat websocket connected")
+ logger.info("Mochat websocket connected")
subscribed = await self._subscribe_all()
self._ws_ready = subscribed
@@ -400,13 +400,13 @@ class MoltchatChannel(BaseChannel):
return
self._ws_connected = False
self._ws_ready = False
- logger.warning("Moltchat websocket disconnected")
+ logger.warning("Mochat websocket disconnected")
await self._ensure_fallback_workers()
@client.event
async def connect_error(data: Any) -> None:
message = str(data)
- logger.error(f"Moltchat websocket connect error: {message}")
+ logger.error(f"Mochat websocket connect error: {message}")
@client.on("claw.session.events")
async def on_session_events(payload: dict[str, Any]) -> None:
@@ -441,7 +441,7 @@ class MoltchatChannel(BaseChannel):
)
return True
except Exception as e:
- logger.error(f"Failed to connect Moltchat websocket: {e}")
+ logger.error(f"Failed to connect Mochat websocket: {e}")
try:
await client.disconnect()
except Exception:
@@ -486,7 +486,7 @@ class MoltchatChannel(BaseChannel):
},
)
if not ack.get("result"):
- logger.error(f"Moltchat subscribeSessions failed: {ack.get('message', 'unknown error')}")
+ logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}")
return False
data = ack.get("data")
@@ -516,7 +516,7 @@ class MoltchatChannel(BaseChannel):
},
)
if not ack.get("result"):
- logger.error(f"Moltchat subscribePanels failed: {ack.get('message', 'unknown error')}")
+ logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}")
return False
return True
@@ -544,7 +544,7 @@ class MoltchatChannel(BaseChannel):
try:
await self._refresh_targets(subscribe_new=self._ws_ready)
except Exception as e:
- logger.warning(f"Moltchat refresh failed: {e}")
+ logger.warning(f"Mochat refresh failed: {e}")
if self._fallback_mode:
await self._ensure_fallback_workers()
@@ -560,7 +560,7 @@ class MoltchatChannel(BaseChannel):
try:
response = await self._list_sessions()
except Exception as e:
- logger.warning(f"Moltchat listSessions failed: {e}")
+ logger.warning(f"Mochat listSessions failed: {e}")
return
sessions = response.get("sessions")
@@ -599,7 +599,7 @@ class MoltchatChannel(BaseChannel):
try:
response = await self._get_workspace_group()
except Exception as e:
- logger.warning(f"Moltchat getWorkspaceGroup failed: {e}")
+ logger.warning(f"Mochat getWorkspaceGroup failed: {e}")
return
raw_panels = response.get("panels")
@@ -683,7 +683,7 @@ class MoltchatChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
- logger.warning(f"Moltchat watch fallback error ({session_id}): {e}")
+ logger.warning(f"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:
@@ -723,7 +723,7 @@ class MoltchatChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
- logger.warning(f"Moltchat panel polling error ({panel_id}): {e}")
+ logger.warning(f"Mochat panel polling error ({panel_id}): {e}")
await asyncio.sleep(sleep_s)
@@ -803,7 +803,7 @@ class MoltchatChannel(BaseChannel):
if message_id and self._remember_message_id(seen_key, message_id):
return
- raw_body = normalize_moltchat_content(payload.get("content"))
+ raw_body = normalize_mochat_content(payload.get("content"))
if not raw_body:
raw_body = "[empty message]"
@@ -826,7 +826,7 @@ class MoltchatChannel(BaseChannel):
if require_mention and not was_mentioned and not use_delay:
return
- entry = MoltchatBufferedEntry(
+ entry = MochatBufferedEntry(
raw_body=raw_body,
author=author,
sender_name=sender_name,
@@ -883,7 +883,7 @@ class MoltchatChannel(BaseChannel):
key: str,
target_id: str,
target_kind: str,
- entry: MoltchatBufferedEntry,
+ entry: MochatBufferedEntry,
) -> None:
state = self._delay_states.setdefault(key, DelayState())
@@ -912,7 +912,7 @@ class MoltchatChannel(BaseChannel):
target_id: str,
target_kind: str,
reason: str,
- entry: MoltchatBufferedEntry | None,
+ entry: MochatBufferedEntry | None,
) -> None:
state = self._delay_states.setdefault(key, DelayState())
@@ -944,7 +944,7 @@ class MoltchatChannel(BaseChannel):
self,
target_id: str,
target_kind: str,
- entries: list[MoltchatBufferedEntry],
+ entries: list[MochatBufferedEntry],
was_mentioned: bool,
) -> None:
if not entries:
@@ -1092,7 +1092,7 @@ class MoltchatChannel(BaseChannel):
try:
data = json.loads(self._cursor_path.read_text("utf-8"))
except Exception as e:
- logger.warning(f"Failed to read Moltchat cursor file: {e}")
+ logger.warning(f"Failed to read Mochat cursor file: {e}")
return
cursors = data.get("cursors") if isinstance(data, dict) else None
@@ -1114,14 +1114,14 @@ class MoltchatChannel(BaseChannel):
self._state_dir.mkdir(parents=True, exist_ok=True)
self._cursor_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", "utf-8")
except Exception as e:
- logger.warning(f"Failed to save Moltchat cursor file: {e}")
+ logger.warning(f"Failed to save Mochat cursor file: {e}")
def _base_url(self) -> str:
return self.config.base_url.strip().rstrip("/")
async def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
if not self._http:
- raise RuntimeError("Moltchat HTTP client not initialized")
+ raise RuntimeError("Mochat HTTP client not initialized")
url = f"{self._base_url()}{path}"
response = await self._http.post(
@@ -1135,7 +1135,7 @@ class MoltchatChannel(BaseChannel):
text = response.text
if not response.is_success:
- raise RuntimeError(f"Moltchat HTTP {response.status_code}: {text[:200]}")
+ raise RuntimeError(f"Mochat HTTP {response.status_code}: {text[:200]}")
parsed: Any
try:
@@ -1146,7 +1146,7 @@ class MoltchatChannel(BaseChannel):
if isinstance(parsed, dict) and isinstance(parsed.get("code"), int):
if parsed["code"] != 200:
message = str(parsed.get("message") or parsed.get("name") or "request failed")
- raise RuntimeError(f"Moltchat API error: {message} (code={parsed['code']})")
+ raise RuntimeError(f"Mochat API error: {message} (code={parsed['code']})")
data = parsed.get("data")
return data if isinstance(data, dict) else {}
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2039f82..3094aa1 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -376,11 +376,11 @@ def channels_status():
fs_config
)
- # Moltchat
- mc = config.channels.moltchat
+ # Mochat
+ mc = config.channels.mochat
mc_base = mc.base_url or "[dim]not configured[/dim]"
table.add_row(
- "Moltchat",
+ "Mochat",
"✓" if mc.enabled else "✗",
mc_base
)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 4df4251..1d6ca9e 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -39,18 +39,18 @@ class DiscordConfig(BaseModel):
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
-class MoltchatMentionConfig(BaseModel):
- """Moltchat mention behavior configuration."""
+class MochatMentionConfig(BaseModel):
+ """Mochat mention behavior configuration."""
require_in_groups: bool = False
-class MoltchatGroupRule(BaseModel):
- """Moltchat per-group mention requirement."""
+class MochatGroupRule(BaseModel):
+ """Mochat per-group mention requirement."""
require_mention: bool = False
-class MoltchatConfig(BaseModel):
- """Moltchat channel configuration."""
+class MochatConfig(BaseModel):
+ """Mochat channel configuration."""
enabled: bool = False
base_url: str = "http://localhost:11000"
socket_url: str = ""
@@ -69,8 +69,8 @@ class MoltchatConfig(BaseModel):
sessions: list[str] = Field(default_factory=list)
panels: list[str] = Field(default_factory=list)
allow_from: list[str] = Field(default_factory=list)
- mention: MoltchatMentionConfig = Field(default_factory=MoltchatMentionConfig)
- groups: dict[str, MoltchatGroupRule] = Field(default_factory=dict)
+ 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
@@ -81,7 +81,7 @@ class ChannelsConfig(BaseModel):
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
discord: DiscordConfig = Field(default_factory=DiscordConfig)
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
- moltchat: MoltchatConfig = Field(default_factory=MoltchatConfig)
+ mochat: MochatConfig = Field(default_factory=MochatConfig)
class AgentDefaults(BaseModel):
diff --git a/tests/test_moltchat_channel.py b/tests/test_mochat_channel.py
similarity index 73%
rename from tests/test_moltchat_channel.py
rename to tests/test_mochat_channel.py
index 1f65a68..4d73840 100644
--- a/tests/test_moltchat_channel.py
+++ b/tests/test_mochat_channel.py
@@ -1,27 +1,27 @@
import pytest
from nanobot.bus.queue import MessageBus
-from nanobot.channels.moltchat import (
- MoltchatBufferedEntry,
- MoltchatChannel,
+from nanobot.channels.mochat import (
+ MochatBufferedEntry,
+ MochatChannel,
build_buffered_body,
- resolve_moltchat_target,
+ resolve_mochat_target,
resolve_require_mention,
resolve_was_mentioned,
)
-from nanobot.config.schema import MoltchatConfig, MoltchatGroupRule, MoltchatMentionConfig
+from nanobot.config.schema import MochatConfig, MochatGroupRule, MochatMentionConfig
-def test_resolve_moltchat_target_prefixes() -> None:
- t = resolve_moltchat_target("panel:abc")
+def test_resolve_mochat_target_prefixes() -> None:
+ t = resolve_mochat_target("panel:abc")
assert t.id == "abc"
assert t.is_panel is True
- t = resolve_moltchat_target("session_123")
+ t = resolve_mochat_target("session_123")
assert t.id == "session_123"
assert t.is_panel is False
- t = resolve_moltchat_target("mochat:session_456")
+ t = resolve_mochat_target("mochat:session_456")
assert t.id == "session_456"
assert t.is_panel is False
@@ -40,12 +40,12 @@ def test_resolve_was_mentioned_from_meta_and_text() -> None:
def test_resolve_require_mention_priority() -> None:
- cfg = MoltchatConfig(
+ cfg = MochatConfig(
groups={
- "*": MoltchatGroupRule(require_mention=False),
- "group-a": MoltchatGroupRule(require_mention=True),
+ "*": MochatGroupRule(require_mention=False),
+ "group-a": MochatGroupRule(require_mention=True),
},
- mention=MoltchatMentionConfig(require_in_groups=False),
+ mention=MochatMentionConfig(require_in_groups=False),
)
assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-a") is True
@@ -55,14 +55,14 @@ def test_resolve_require_mention_priority() -> None:
@pytest.mark.asyncio
async def test_delay_buffer_flushes_on_mention() -> None:
bus = MessageBus()
- cfg = MoltchatConfig(
+ cfg = MochatConfig(
enabled=True,
claw_token="token",
agent_user_id="bot",
reply_delay_mode="non-mention",
reply_delay_ms=60_000,
)
- channel = MoltchatChannel(cfg, bus)
+ channel = MochatChannel(cfg, bus)
first = {
"type": "message.add",
@@ -94,7 +94,7 @@ async def test_delay_buffer_flushes_on_mention() -> None:
assert bus.inbound_size == 1
msg = await bus.consume_inbound()
- assert msg.channel == "moltchat"
+ assert msg.channel == "mochat"
assert msg.chat_id == "panel-1"
assert "user1: first" in msg.content
assert "user2: hello <@bot>" in msg.content
@@ -106,8 +106,8 @@ async def test_delay_buffer_flushes_on_mention() -> None:
def test_build_buffered_body_group_labels() -> None:
body = build_buffered_body(
entries=[
- MoltchatBufferedEntry(raw_body="a", author="u1", sender_name="Alice"),
- MoltchatBufferedEntry(raw_body="b", author="u2", sender_username="bot"),
+ MochatBufferedEntry(raw_body="a", author="u1", sender_name="Alice"),
+ MochatBufferedEntry(raw_body="b", author="u2", sender_username="bot"),
],
is_group=True,
)
From 866942eedd02a3fc85ae4e0393c450a2394b8922 Mon Sep 17 00:00:00 2001
From: tjb-tech
Date: Mon, 9 Feb 2026 09:12:53 +0000
Subject: [PATCH 042/506] fix: update agentUserId in README and change base_url
to HTTPS in configuration
---
README.md | 2 +-
nanobot/config/schema.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index d15fd2f..7bf98fd 100644
--- a/README.md
+++ b/README.md
@@ -231,7 +231,7 @@ Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
"socketUrl": "https://mochat.io",
"socketPath": "/socket.io",
"clawToken": "claw_xxx",
- "agentUserId": "69820107a785110aea8b1069",
+ "agentUserId": "6982abcdef",
"sessions": ["*"],
"panels": ["*"],
"replyDelayMode": "non-mention",
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index a3d8aa5..26abcd7 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -90,7 +90,7 @@ class MochatGroupRule(BaseModel):
class MochatConfig(BaseModel):
"""Mochat channel configuration."""
enabled: bool = False
- base_url: str = "http://localhost:11000"
+ base_url: str = "https://mochat.io"
socket_url: str = ""
socket_path: str = "/socket.io"
socket_disable_msgpack: bool = False
From 7ffd90aa3b519278ca3eda0ee2ab2a0bba430c98 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 10:59:16 +0000
Subject: [PATCH 043/506] docs: update email channel tips
---
README.md | 1 -
1 file changed, 1 deletion(-)
diff --git a/README.md b/README.md
index 8f7c1a2..4106b2a 100644
--- a/README.md
+++ b/README.md
@@ -494,7 +494,6 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
### Security
-> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
| Option | Default | Description |
From f3ab8066a70c72dfc9788f3f1c6ba912456133cd Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 11:39:13 +0000
Subject: [PATCH 044/506] fix: use websockets backend, simplify subtype check,
add Slack docs
---
README.md | 43 +++++++++++++++++++++++++++++++++++++--
nanobot/agent/loop.py | 2 +-
nanobot/channels/slack.py | 6 +++---
3 files changed, 45 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 4106b2a..186fe35 100644
--- a/README.md
+++ b/README.md
@@ -166,7 +166,7 @@ nanobot agent -m "Hello from my local LLM!"
## 💬 Chat Apps
-Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or Email — anytime, anywhere.
+Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Slack, or Email — anytime, anywhere.
| Channel | Setup |
|---------|-------|
@@ -175,6 +175,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or E
| **WhatsApp** | Medium (scan QR) |
| **Feishu** | Medium (app credentials) |
| **DingTalk** | Medium (app credentials) |
+| **Slack** | Medium (bot + app tokens) |
| **Email** | Medium (IMAP/SMTP credentials) |
@@ -374,6 +375,44 @@ nanobot gateway
+
+Slack
+
+Uses **Socket Mode** — no public URL required.
+
+**1. Create a Slack app**
+- Go to [Slack API](https://api.slack.com/apps) → Create New App
+- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
+- Install to your workspace and copy the **Bot Token** (`xoxb-...`)
+- **Socket Mode**: Enable it and generate an **App-Level Token** (`xapp-...`) with `connections:write` scope
+- **Event Subscriptions**: Subscribe to `message.im`, `message.channels`, `app_mention`
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "botToken": "xoxb-...",
+ "appToken": "xapp-...",
+ "groupPolicy": "mention"
+ }
+ }
+}
+```
+
+> `groupPolicy`: `"mention"` (respond only when @mentioned), `"open"` (respond to all messages), or `"allowlist"` (restrict to specific channels).
+> DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+
+
Email
@@ -592,7 +631,7 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
- [ ] **Multi-modal** — See and hear (images, voice, video)
- [ ] **Long-term memory** — Never forget important context
- [ ] **Better reasoning** — Multi-step planning and reflection
-- [ ] **More integrations** — Slack, calendar, and more
+- [ ] **More integrations** — Calendar and more
- [ ] **Self-improvement** — Learn from feedback and mistakes
### Contributors
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 64c95ba..b764c3d 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -246,7 +246,7 @@ class AgentLoop:
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
- metadata=msg.metadata or {},
+ metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index 32abe3b..be95dd2 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -5,7 +5,7 @@ import re
from typing import Any
from loguru import logger
-from slack_sdk.socket_mode.aiohttp import SocketModeClient
+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
@@ -115,8 +115,8 @@ class SlackChannel(BaseChannel):
sender_id = event.get("user")
chat_id = event.get("channel")
- # Ignore bot/system messages to prevent loops
- if event.get("subtype") == "bot_message" or event.get("subtype"):
+ # 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
From a63a44fa798aa86a3f6a79d12db2e38700d4e068 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 12:04:34 +0000
Subject: [PATCH 045/506] fix: align QQ channel with BaseChannel conventions,
simplify implementation
---
.gitignore | 2 +-
nanobot/channels/qq.py | 156 ++++++++++-------------------------------
2 files changed, 39 insertions(+), 119 deletions(-)
diff --git a/.gitignore b/.gitignore
index 4e58574..36dbfc2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,4 @@ __pycache__/
poetry.lock
.pytest_cache/
tests/
-botpy.log
\ No newline at end of file
+botpy.log
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index 98ca883..e3efb4f 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
from loguru import logger
-from nanobot.bus.events import InboundMessage, OutboundMessage
+from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import QQConfig
@@ -25,31 +25,28 @@ if TYPE_CHECKING:
from botpy.message import C2CMessage
-def parse_chat_id(chat_id: str) -> tuple[str, str]:
- """Parse chat_id into (channel, user_id).
+def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
+ """Create a botpy Client subclass bound to the given channel."""
+ intents = botpy.Intents(c2c_message=True)
- Args:
- chat_id: Format "channel:user_id", e.g. "qq:openid_xxx"
+ class _Bot(botpy.Client):
+ def __init__(self):
+ super().__init__(intents=intents)
- Returns:
- Tuple of (channel, user_id)
- """
- if ":" not in chat_id:
- raise ValueError(f"Invalid chat_id format: {chat_id}")
- channel, user_id = chat_id.split(":", 1)
- return channel, user_id
+ async def on_ready(self):
+ logger.info(f"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.
-
- Uses botpy SDK to connect to QQ Open Platform (q.qq.com).
-
- Requires:
- - App ID and Secret from q.qq.com
- - Robot capability enabled
- """
+ """QQ channel using botpy SDK with WebSocket connection."""
name = "qq"
@@ -57,79 +54,43 @@ class QQChannel(BaseChannel):
super().__init__(config, bus)
self.config: QQConfig = config
self._client: "botpy.Client | None" = None
- self._processed_message_ids: deque = deque(maxlen=1000)
+ self._processed_ids: deque = deque(maxlen=1000)
self._bot_task: asyncio.Task | None = None
async def start(self) -> None:
"""Start the QQ bot."""
if not QQ_AVAILABLE:
- logger.error("QQ SDK 未安装。请运行:pip install qq-botpy")
+ 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 和 secret 未配置")
+ logger.error("QQ app_id and secret not configured")
return
self._running = True
+ BotClass = _make_bot_class(self)
+ self._client = BotClass()
- # Create bot client with C2C intents
- intents = botpy.Intents.all()
- logger.info(f"QQ Intents 配置值: {intents.value}")
+ self._bot_task = asyncio.create_task(self._run_bot())
+ logger.info("QQ bot started (C2C private message)")
- # Create custom bot class with message handlers
- class QQBot(botpy.Client):
- def __init__(self, channel):
- super().__init__(intents=intents)
- self.channel = channel
-
- async def on_ready(self):
- """Called when bot is ready."""
- logger.info(f"QQ bot ready: {self.robot.name}")
-
- async def on_c2c_message_create(self, message: "C2CMessage"):
- """Handle C2C (Client to Client) messages - private chat."""
- await self.channel._on_message(message, "c2c")
-
- async def on_direct_message_create(self, message):
- """Handle direct messages - alternative event name."""
- await self.channel._on_message(message, "direct")
-
- # TODO: Group message support - implement in future PRD
- # async def on_group_at_message_create(self, message):
- # """Handle group @ messages."""
- # pass
-
- self._client = QQBot(self)
-
- # Start bot - use create_task to run concurrently
- self._bot_task = asyncio.create_task(
- self._run_bot_with_retry(self.config.app_id, self.config.secret)
- )
-
- logger.info("QQ bot started with C2C (private message) support")
-
- async def _run_bot_with_retry(self, app_id: str, secret: str) -> None:
- """Run bot with error handling."""
+ async def _run_bot(self) -> None:
+ """Run the bot connection."""
try:
- await self._client.start(appid=app_id, secret=secret)
+ await self._client.start(appid=self.config.app_id, secret=self.config.secret)
except Exception as e:
- logger.error(
- f"QQ 鉴权失败,请检查 AppID 和 Secret 是否正确。"
- f"访问 q.qq.com 获取凭证。错误: {e}"
- )
+ logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}")
self._running = False
async def stop(self) -> None:
"""Stop the QQ bot."""
self._running = False
-
if self._bot_task:
self._bot_task.cancel()
try:
await self._bot_task
except asyncio.CancelledError:
pass
-
logger.info("QQ bot stopped")
async def send(self, msg: OutboundMessage) -> None:
@@ -137,75 +98,34 @@ class QQChannel(BaseChannel):
if not self._client:
logger.warning("QQ client not initialized")
return
-
try:
- # Parse chat_id format: qq:{user_id}
- channel, user_id = parse_chat_id(msg.chat_id)
-
- if channel != "qq":
- logger.warning(f"Invalid channel in chat_id: {msg.chat_id}")
- return
-
- # Send private message using botpy API
await self._client.api.post_c2c_message(
- openid=user_id,
+ openid=msg.chat_id,
msg_type=0,
content=msg.content,
)
- logger.debug(f"QQ message sent to {msg.chat_id}")
-
- except ValueError as e:
- logger.error(f"Invalid chat_id format: {e}")
except Exception as e:
logger.error(f"Error sending QQ message: {e}")
- async def _on_message(self, data: "C2CMessage", msg_type: str) -> None:
+ async def _on_message(self, data: "C2CMessage") -> None:
"""Handle incoming message from QQ."""
try:
- # Message deduplication using deque with maxlen
- message_id = data.id
- if message_id in self._processed_message_ids:
- logger.debug(f"Duplicate message {message_id}, skipping")
+ # Dedup by message ID
+ if data.id in self._processed_ids:
return
+ self._processed_ids.append(data.id)
- self._processed_message_ids.append(message_id)
-
- # Extract user ID and chat ID from message
author = data.author
- # Try different possible field names for user ID
user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown'))
- user_name = getattr(author, 'username', None) or 'unknown'
-
- # For C2C messages, chat_id is the user's ID
- chat_id = f"qq:{user_id}"
-
- # Check allow_from list (if configured)
- if self.config.allow_from and user_id not in self.config.allow_from:
- logger.info(f"User {user_id} not in allow_from list")
- return
-
- # Get message content
- content = data.content or ""
-
+ content = (data.content or "").strip()
if not content:
- logger.debug(f"Empty message from {user_id}, skipping")
return
- # Publish to message bus
- msg = InboundMessage(
- channel=self.name,
+ await self._handle_message(
sender_id=user_id,
- chat_id=chat_id,
+ chat_id=user_id,
content=content,
- metadata={
- "message_id": message_id,
- "user_name": user_name,
- "msg_type": msg_type,
- },
+ metadata={"message_id": data.id},
)
- await self.bus.publish_inbound(msg)
-
- logger.info(f"Received QQ message from {user_id} ({msg_type}): {content[:50]}")
-
except Exception as e:
logger.error(f"Error handling QQ message: {e}")
From 1e95f8b486771b99708dbafb94f236939149acba Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 12:07:45 +0000
Subject: [PATCH 046/506] docs: add 9 feb news
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 6cde257..d5a1e17 100644
--- a/README.md
+++ b/README.md
@@ -16,10 +16,11 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,479 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
+- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
From 03d3c69a4ad0f9181965808798d45c52f9126072 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 12:40:24 +0000
Subject: [PATCH 047/506] docs: improve Email channel setup guide
---
README.md | 24 +++++++++++-------------
1 file changed, 11 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index d5a1e17..7e1f80b 100644
--- a/README.md
+++ b/README.md
@@ -459,17 +459,19 @@ nanobot gateway
Email
-Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit consent before accessing mailbox data.
+Give nanobot its own email account. It polls **IMAP** for incoming mail and replies via **SMTP** — like a personal email assistant.
**1. Get credentials (Gmail example)**
-- Enable 2-Step Verification in Google account security
-- Create an [App Password](https://myaccount.google.com/apppasswords)
+- Create a dedicated Gmail account for your bot (e.g. `my-nanobot@gmail.com`)
+- Enable 2-Step Verification → Create an [App Password](https://myaccount.google.com/apppasswords)
- Use this app password for both IMAP and SMTP
**2. Configure**
-> [!TIP]
-> Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
+> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate — set `false` to fully disable.
+> - `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific senders.
+> - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly.
+> - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
```json
{
@@ -479,23 +481,19 @@ Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit con
"consentGranted": true,
"imapHost": "imap.gmail.com",
"imapPort": 993,
- "imapUsername": "you@gmail.com",
+ "imapUsername": "my-nanobot@gmail.com",
"imapPassword": "your-app-password",
- "imapUseSsl": true,
"smtpHost": "smtp.gmail.com",
"smtpPort": 587,
- "smtpUsername": "you@gmail.com",
+ "smtpUsername": "my-nanobot@gmail.com",
"smtpPassword": "your-app-password",
- "smtpUseTls": true,
- "fromAddress": "you@gmail.com",
- "allowFrom": ["trusted@example.com"]
+ "fromAddress": "my-nanobot@gmail.com",
+ "allowFrom": ["your-real-email@gmail.com"]
}
}
}
```
-> `consentGranted`: Must be `true` to allow mailbox access. Set to `false` to disable reading and sending entirely.
-> `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific sender addresses.
**3. Run**
From 4f928e9d2a27879b95b1f16286a7aeeab27feed1 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 16:17:35 +0000
Subject: [PATCH 048/506] feat: improve QQ channel setup guide and fix botpy
intent flags
---
README.md | 28 ++++++++++++++++------------
nanobot/channels/qq.py | 2 +-
2 files changed, 17 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 7e1f80b..eb2ff7f 100644
--- a/README.md
+++ b/README.md
@@ -341,16 +341,24 @@ nanobot gateway
-QQ (QQ私聊)
+QQ (QQ单聊)
-Uses **botpy SDK** with WebSocket — no public IP required.
+Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**.
-**1. Create a QQ bot**
-- Visit [QQ Open Platform](https://q.qq.com)
+**1. Register & create bot**
+- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise)
- Create a new bot application
-- Get **AppID** and **Secret** from "Developer Settings"
+- Go to **开发设置 (Developer Settings)** → copy **AppID** and **AppSecret**
-**2. Configure**
+**2. Set up sandbox for testing**
+- In the bot management console, find **沙箱配置 (Sandbox Config)**
+- Under **在消息列表配置**, click **添加成员** and add your own QQ number
+- Once added, scan the bot's QR code with mobile QQ → open the bot profile → tap "发消息" to start chatting
+
+**3. Configure**
+
+> - `allowFrom`: Leave empty for public access, or add user openids to restrict. You can find openids in the nanobot logs when a user messages the bot.
+> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow.
```json
{
@@ -365,17 +373,13 @@ Uses **botpy SDK** with WebSocket — no public IP required.
}
```
-> `allowFrom`: Leave empty for public access, or add user openids to restrict access.
-> Example: `"allowFrom": ["user_openid_1", "user_openid_2"]`
-
-**3. Run**
+**4. Run**
```bash
nanobot gateway
```
-> [!TIP]
-> QQ bot currently supports **private messages only**. Group chat support coming soon!
+Now send a message to the bot from QQ — it should respond!
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index e3efb4f..5964d30 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -27,7 +27,7 @@ if TYPE_CHECKING:
def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
"""Create a botpy Client subclass bound to the given channel."""
- intents = botpy.Intents(c2c_message=True)
+ intents = botpy.Intents(public_messages=True, direct_message=True)
class _Bot(botpy.Client):
def __init__(self):
From ec4340d0d8d2c6667d7977f87cc7bb3f3ffd5b62 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Feb 2026 16:49:13 +0000
Subject: [PATCH 049/506] feat: add App Home step to Slack guide, default
groupPolicy to mention
---
README.md | 27 +++++++++++++++++----------
nanobot/config/schema.py | 2 +-
2 files changed, 18 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index eb2ff7f..2a6d29d 100644
--- a/README.md
+++ b/README.md
@@ -428,13 +428,17 @@ nanobot gateway
Uses **Socket Mode** — no public URL required.
**1. Create a Slack app**
-- Go to [Slack API](https://api.slack.com/apps) → Create New App
-- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
-- Install to your workspace and copy the **Bot Token** (`xoxb-...`)
-- **Socket Mode**: Enable it and generate an **App-Level Token** (`xapp-...`) with `connections:write` scope
-- **Event Subscriptions**: Subscribe to `message.im`, `message.channels`, `app_mention`
+- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch"
+- Pick a name and select your workspace
-**2. Configure**
+**2. Configure the app**
+- **Socket Mode**: Toggle ON → Generate an **App-Level Token** with `connections:write` scope → copy it (`xapp-...`)
+- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
+- **Event Subscriptions**: Toggle ON → Subscribe to bot events: `message.im`, `message.channels`, `app_mention` → Save Changes
+- **App Home**: Scroll to **Show Tabs** → Enable **Messages Tab** → Check **"Allow users to send Slash commands and messages from the messages tab"**
+- **Install App**: Click **Install to Workspace** → Authorize → copy the **Bot Token** (`xoxb-...`)
+
+**3. Configure nanobot**
```json
{
@@ -449,15 +453,18 @@ Uses **Socket Mode** — no public URL required.
}
```
-> `groupPolicy`: `"mention"` (respond only when @mentioned), `"open"` (respond to all messages), or `"allowlist"` (restrict to specific channels).
-> DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
-
-**3. Run**
+**4. Run**
```bash
nanobot gateway
```
+DM the bot directly or @mention it in a channel — it should respond!
+
+> [!TIP]
+> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels).
+> - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
+
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 1aae587..fe0259e 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -92,7 +92,7 @@ class SlackConfig(BaseModel):
bot_token: str = "" # xoxb-...
app_token: str = "" # xapp-...
user_token_read_only: bool = True
- group_policy: str = "open" # "open", "mention", "allowlist"
+ 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)
From fba5345d20b793765582a4f94db3a0e9813349a7 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 02:09:31 +0000
Subject: [PATCH 050/506] fix: pass api_key directly to litellm for more robust
auth
---
nanobot/providers/litellm_provider.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 9d76c2a..dd50ed9 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -132,6 +132,10 @@ class LiteLLMProvider(LLMProvider):
# Apply model-specific overrides (e.g. kimi-k2.5 temperature)
self._apply_model_overrides(model, kwargs)
+ # Pass api_key directly — more reliable than env vars alone
+ if self.api_key:
+ kwargs["api_key"] = self.api_key
+
# Pass api_base for custom endpoints
if self.api_base:
kwargs["api_base"] = self.api_base
From fc9dc4b39718860966f3772944a8a994b4bbe40e Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 03:00:42 +0000
Subject: [PATCH 051/506] Release v0.1.3.post6
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 8662f58..63e148d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
-version = "0.1.3.post5"
+version = "0.1.3.post6"
description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11"
license = {text = "MIT"}
From 76e51ca8def96567aef2c893e9c226d92bdc8ba5 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 03:07:27 +0000
Subject: [PATCH 052/506] docs: release v0.1.3.post6
---
README.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 2a6d29d..21c8613 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
## 📢 News
+- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap discussion](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
@@ -689,7 +690,7 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
### Contributors
-
+
From a779f8c453299891415ee37924f7c6757ab8f03f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 03:08:17 +0000
Subject: [PATCH 053/506] docs: update release news
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 21c8613..8503b6c 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## 📢 News
-- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap discussion](https://github.com/HKUDS/nanobot/discussions/431).
+- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check the [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
From f634658707ccc1bad59276f8d484bc3ca9040346 Mon Sep 17 00:00:00 2001
From: ouyangwulin
Date: Tue, 10 Feb 2026 11:10:00 +0800
Subject: [PATCH 054/506] fixed dingtalk exception.
---
pyproject.toml | 1 +
1 file changed, 1 insertion(+)
diff --git a/pyproject.toml b/pyproject.toml
index 8662f58..6413c47 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,6 +34,7 @@ dependencies = [
"socksio>=1.0.0",
"slack-sdk>=3.26.0",
"qq-botpy>=1.0.0",
+ "python-socks[asyncio]>=2.4.0",
]
[project.optional-dependencies]
From ba2bdb080de75079b5457fb98bd39aef3797b13b Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 07:06:04 +0000
Subject: [PATCH 055/506] refactor: streamline mochat channel
---
nanobot/channels/__init__.py | 3 +-
nanobot/channels/mochat.py | 920 +++++++++++------------------------
2 files changed, 295 insertions(+), 628 deletions(-)
diff --git a/nanobot/channels/__init__.py b/nanobot/channels/__init__.py
index 034d401..588169d 100644
--- a/nanobot/channels/__init__.py
+++ b/nanobot/channels/__init__.py
@@ -2,6 +2,5 @@
from nanobot.channels.base import BaseChannel
from nanobot.channels.manager import ChannelManager
-from nanobot.channels.mochat import MochatChannel
-__all__ = ["BaseChannel", "ChannelManager", "MochatChannel"]
+__all__ = ["BaseChannel", "ChannelManager"]
diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py
index 6569cdd..30c3dbf 100644
--- a/nanobot/channels/mochat.py
+++ b/nanobot/channels/mochat.py
@@ -20,7 +20,6 @@ from nanobot.utils.helpers import get_data_path
try:
import socketio
-
SOCKETIO_AVAILABLE = True
except ImportError:
socketio = None
@@ -28,20 +27,21 @@ except ImportError:
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 = ""
@@ -54,7 +54,6 @@ class MochatBufferedEntry:
@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
@@ -63,11 +62,48 @@ class DelayState:
@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):
@@ -87,20 +123,15 @@ def resolve_mochat_target(raw: str) -> MochatTarget:
return MochatTarget(id="", is_panel=False)
lowered = trimmed.lower()
- cleaned = trimmed
- forced_panel = False
-
- prefixes = ["mochat:", "group:", "channel:", "panel:"]
- for prefix in prefixes:
+ cleaned, forced_panel = trimmed, False
+ for prefix in ("mochat:", "group:", "channel:", "panel:"):
if lowered.startswith(prefix):
- cleaned = trimmed[len(prefix) :].strip()
- if prefix in {"group:", "channel:", "panel:"}:
- forced_panel = True
+ 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_"))
@@ -108,24 +139,17 @@ 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):
- text = item.strip()
- if text:
- ids.append(text)
- continue
-
- if not isinstance(item, dict):
- continue
-
- for key in ("id", "userId", "_id"):
- candidate = item.get(key)
- if isinstance(candidate, str) and candidate.strip():
- ids.append(candidate.strip())
- break
-
+ 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
@@ -135,35 +159,23 @@ def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool:
if isinstance(meta, dict):
if meta.get("mentioned") is True or meta.get("wasMentioned") is True:
return True
-
- for field in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"):
- ids = extract_mention_ids(meta.get(field))
- if agent_user_id and agent_user_id in ids:
+ 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:
+def resolve_require_mention(config: MochatConfig, session_id: str, group_id: str) -> bool:
"""Resolve mention requirement for group/panel conversations."""
groups = config.groups or {}
- if group_id and group_id in groups:
- return bool(groups[group_id].require_mention)
- if session_id in groups:
- return bool(groups[session_id].require_mention)
- if "*" in groups:
- return bool(groups["*"].require_mention)
+ 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)
@@ -171,22 +183,18 @@ def build_buffered_body(entries: list[MochatBufferedEntry], is_group: bool) -> s
"""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:
- body = entry.raw_body
- if not body:
+ 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}: {body}")
+ lines.append(f"{label}: {entry.raw_body}")
continue
- lines.append(body)
-
+ lines.append(entry.raw_body)
return "\n".join(lines).strip()
@@ -200,6 +208,10 @@ def parse_timestamp(value: Any) -> int | None:
return None
+# ---------------------------------------------------------------------------
+# Channel
+# ---------------------------------------------------------------------------
+
class MochatChannel(BaseChannel):
"""Mochat channel using socket.io with fallback polling workers."""
@@ -210,8 +222,7 @@ class MochatChannel(BaseChannel):
self.config: MochatConfig = config
self._http: httpx.AsyncClient | None = None
self._socket: Any = None
- self._ws_connected = False
- self._ws_ready = False
+ self._ws_connected = self._ws_ready = False
self._state_dir = get_data_path() / "mochat"
self._cursor_path = self._state_dir / "session_cursors.json"
@@ -220,24 +231,23 @@ class MochatChannel(BaseChannel):
self._session_set: set[str] = set()
self._panel_set: set[str] = set()
- self._auto_discover_sessions = False
- self._auto_discover_panels = False
+ 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:
@@ -246,26 +256,21 @@ class MochatChannel(BaseChannel):
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)
- websocket_started = await self._start_socket_client()
- if not websocket_started:
+ 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
@@ -283,15 +288,12 @@ class MochatChannel(BaseChannel):
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 = False
- self._ws_ready = False
+ self._ws_connected = self._ws_ready = False
async def send(self, msg: OutboundMessage) -> None:
"""Send outbound message to session or panel."""
@@ -299,10 +301,10 @@ class MochatChannel(BaseChannel):
logger.warning("Mochat claw_token missing, skip send")
return
- content_parts = [msg.content.strip()] if msg.content and msg.content.strip() else []
+ parts = ([msg.content.strip()] if msg.content and msg.content.strip() else [])
if msg.media:
- content_parts.extend([m for m in msg.media if isinstance(m, str) and m.strip()])
- content = "\n".join(content_parts).strip()
+ parts.extend(m for m in msg.media if isinstance(m, str) and m.strip())
+ content = "\n".join(parts).strip()
if not content:
return
@@ -311,43 +313,34 @@ class MochatChannel(BaseChannel):
logger.warning("Mochat outbound target is empty")
return
- is_panel = target.is_panel or target.id in self._panel_set
- if target.id.startswith("session_"):
- is_panel = False
-
+ is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_")
try:
if is_panel:
- await self._send_panel_message(
- panel_id=target.id,
- content=content,
- reply_to=msg.reply_to,
- group_id=self._read_group_id(msg.metadata),
- )
+ 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._send_session_message(
- session_id=target.id,
- content=content,
- reply_to=msg.reply_to,
- )
+ await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
+ content, msg.reply_to)
except Exception as e:
logger.error(f"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)
- for session_id in sessions:
- if session_id not in self._session_cursor:
- self._cold_sessions.add(session_id)
-
- def _normalize_id_list(self, values: list[str]) -> tuple[list[str], bool]:
+ @staticmethod
+ def _normalize_id_list(values: list[str]) -> tuple[list[str], bool]:
cleaned = [str(v).strip() for v in values if str(v).strip()]
- has_wildcard = "*" in cleaned
- ids = sorted({v for v in cleaned if v != "*"})
- return ids, has_wildcard
+ return sorted({v for v in cleaned if v != "*"}), "*" in cleaned
+
+ # ---- websocket ---------------------------------------------------------
async def _start_socket_client(self) -> bool:
if not SOCKETIO_AVAILABLE:
@@ -359,83 +352,56 @@ class MochatChannel(BaseChannel):
if MSGPACK_AVAILABLE:
serializer = "msgpack"
else:
- logger.warning(
- "msgpack is not installed but socket_disable_msgpack=false; "
- "trying JSON serializer"
- )
-
- reconnect_attempts = None
- if self.config.max_retry_attempts > 0:
- reconnect_attempts = self.config.max_retry_attempts
+ logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON")
client = socketio.AsyncClient(
reconnection=True,
- reconnection_attempts=reconnect_attempts,
+ 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,
+ 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 = True
- self._ws_ready = False
+ self._ws_connected, self._ws_ready = True, False
logger.info("Mochat websocket connected")
-
subscribed = await self._subscribe_all()
self._ws_ready = subscribed
- if subscribed:
- await self._stop_fallback_workers()
- else:
- await self._ensure_fallback_workers()
+ 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 = False
- self._ws_ready = False
+ 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:
- message = str(data)
- logger.error(f"Mochat websocket connect error: {message}")
+ logger.error(f"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, target_kind="session")
+ 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, target_kind="panel")
+ await self._handle_watch_payload(payload, "panel")
- for event_name in (
- "notify:chat.inbox.append",
- "notify:chat.message.add",
- "notify:chat.message.update",
- "notify:chat.message.recall",
- "notify:chat.message.delete",
- ):
- client.on(event_name, self._build_notify_handler(event_name))
+ 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()
- if socket_path.startswith("/"):
- socket_path = socket_path[1:]
+ 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,
+ 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),
)
@@ -453,38 +419,30 @@ class MochatChannel(BaseChannel):
async def handler(payload: Any) -> None:
if event_name == "notify:chat.inbox.append":
await self._handle_notify_inbox_append(payload)
- return
-
- if event_name.startswith("notify:chat.message."):
+ elif event_name.startswith("notify:chat.message."):
await self._handle_notify_chat_message(payload)
-
return handler
- async def _subscribe_all(self) -> bool:
- sessions_ok = await self._subscribe_sessions(sorted(self._session_set))
- panels_ok = await self._subscribe_panels(sorted(self._panel_set))
+ # ---- 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 sessions_ok and panels_ok
+ 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)
- for session_id in session_ids:
- if session_id not in self._session_cursor:
- self._cold_sessions.add(session_id)
-
- ack = await self._socket_call(
- "com.claw.im.subscribeSessions",
- {
- "sessionIds": session_ids,
- "cursors": self._session_cursor,
- "limit": self.config.watch_limit,
- },
- )
+ 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(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}")
return False
@@ -492,73 +450,57 @@ class MochatChannel(BaseChannel):
data = ack.get("data")
items: list[dict[str, Any]] = []
if isinstance(data, list):
- items = [item for item in data if isinstance(item, dict)]
+ items = [i for i in data if isinstance(i, dict)]
elif isinstance(data, dict):
sessions = data.get("sessions")
if isinstance(sessions, list):
- items = [item for item in sessions if isinstance(item, dict)]
+ items = [i for i in sessions if isinstance(i, dict)]
elif "sessionId" in data:
items = [data]
-
- for payload in items:
- await self._handle_watch_payload(payload, target_kind="session")
-
+ 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,
- },
- )
+ ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
if not ack.get("result"):
logger.error(f"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}
- if isinstance(raw, dict):
- return raw
-
- return {"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(f"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=subscribe_new)
-
+ await self._refresh_sessions_directory(subscribe_new)
if self._auto_discover_panels:
- await self._refresh_panels(subscribe_new=subscribe_new)
+ await self._refresh_panels(subscribe_new)
async def _refresh_sessions_directory(self, subscribe_new: bool) -> None:
try:
- response = await self._list_sessions()
+ response = await self._post_json("/api/claw/sessions/list", {})
except Exception as e:
logger.warning(f"Mochat listSessions failed: {e}")
return
@@ -567,37 +509,32 @@ class MochatChannel(BaseChannel):
if not isinstance(sessions, list):
return
- new_sessions: list[str] = []
- for session in sessions:
- if not isinstance(session, dict):
+ new_ids: list[str] = []
+ for s in sessions:
+ if not isinstance(s, dict):
continue
-
- session_id = str(session.get("sessionId") or "").strip()
- if not session_id:
+ 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 session_id not in self._session_set:
- self._session_set.add(session_id)
- new_sessions.append(session_id)
- if session_id not in self._session_cursor:
- self._cold_sessions.add(session_id)
-
- converse_id = str(session.get("converseId") or "").strip()
- if converse_id:
- self._session_by_converse[converse_id] = session_id
-
- if not new_sessions:
+ if not new_ids:
return
-
if self._ws_ready and subscribe_new:
- await self._subscribe_sessions(new_sessions)
-
+ 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._get_workspace_group()
+ response = await self._post_json("/api/claw/groups/get", {})
except Exception as e:
logger.warning(f"Mochat getWorkspaceGroup failed: {e}")
return
@@ -606,80 +543,58 @@ class MochatChannel(BaseChannel):
if not isinstance(raw_panels, list):
return
- new_panels: list[str] = []
- for panel in raw_panels:
- if not isinstance(panel, dict):
+ new_ids: list[str] = []
+ for p in raw_panels:
+ if not isinstance(p, dict):
continue
-
- panel_type = panel.get("type")
- if isinstance(panel_type, int) and panel_type != 0:
+ 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)
- panel_id = str(panel.get("id") or panel.get("_id") or "").strip()
- if not panel_id:
- continue
-
- if panel_id not in self._panel_set:
- self._panel_set.add(panel_id)
- new_panels.append(panel_id)
-
- if not new_panels:
+ if not new_ids:
return
-
if self._ws_ready and subscribe_new:
- await self._subscribe_panels(new_panels)
-
+ 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 session_id in sorted(self._session_set):
- task = self._session_fallback_tasks.get(session_id)
- if task and not task.done():
- continue
- self._session_fallback_tasks[session_id] = asyncio.create_task(
- self._session_watch_worker(session_id)
- )
-
- for panel_id in sorted(self._panel_set):
- task = self._panel_fallback_tasks.get(panel_id)
- if task and not task.done():
- continue
- self._panel_fallback_tasks[panel_id] = asyncio.create_task(
- self._panel_poll_worker(panel_id)
- )
+ 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 task in tasks:
- task.cancel()
-
+ 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._watch_session(
- session_id=session_id,
- cursor=self._session_cursor.get(session_id, 0),
- timeout_ms=self.config.watch_timeout_ms,
- limit=self.config.watch_limit,
- )
- await self._handle_watch_payload(payload, target_kind="session")
+ 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:
@@ -688,72 +603,50 @@ class MochatChannel(BaseChannel):
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:
- response = await self._list_panel_messages(
- panel_id=panel_id,
- limit=min(100, max(1, self.config.watch_limit)),
- )
-
- raw_messages = response.get("messages")
- if isinstance(raw_messages, list):
- for message in reversed(raw_messages):
- if not isinstance(message, dict):
+ 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
-
- synthetic_event = {
- "type": "message.add",
- "timestamp": message.get("createdAt") or datetime.utcnow().isoformat(),
- "payload": {
- "messageId": str(message.get("messageId") or ""),
- "author": str(message.get("author") or ""),
- "authorInfo": message.get("authorInfo") if isinstance(message.get("authorInfo"), dict) else {},
- "content": message.get("content"),
- "meta": message.get("meta") if isinstance(message.get("meta"), dict) else {},
- "groupId": str(response.get("groupId") or ""),
- "converseId": panel_id,
- },
- }
- await self._process_inbound_event(
- target_id=panel_id,
- event=synthetic_event,
- target_kind="panel",
+ 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(f"Mochat panel polling error ({panel_id}): {e}")
-
await asyncio.sleep(sleep_s)
- async def _handle_watch_payload(
- self,
- payload: dict[str, Any],
- target_kind: str,
- ) -> None:
+ # ---- 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(payload.get("sessionId") or "").strip()
+ 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:
- previous_cursor = self._session_cursor.get(target_id, 0) if target_kind == "session" else 0
- payload_cursor = payload.get("cursor")
- if (
- target_kind == "session"
- and isinstance(payload_cursor, int)
- and payload_cursor >= 0
- ):
- self._mark_session_cursor(target_id, payload_cursor)
+ 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
@@ -762,324 +655,176 @@ class MochatChannel(BaseChannel):
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, previous_cursor)
- ):
+ 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)
- if event.get("type") != "message.add":
- continue
-
- await self._process_inbound_event(
- target_id=target_id,
- event=event,
- target_kind=target_kind,
- )
-
- async def _process_inbound_event(
- self,
- target_id: str,
- event: dict[str, Any],
- target_kind: str,
- ) -> None:
+ 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(payload.get("author") or "").strip()
- if not author:
+ author = _str_field(payload, "author")
+ if not author or (self.config.agent_user_id and author == self.config.agent_user_id):
return
-
- if self.config.agent_user_id and author == self.config.agent_user_id:
- return
-
if not self.is_allowed(author):
return
- message_id = str(payload.get("messageId") or "").strip()
+ 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"))
- if not raw_body:
- raw_body = "[empty message]"
+ 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")
- author_info = payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {}
- sender_name = str(author_info.get("nickname") or author_info.get("email") or "").strip()
- sender_username = str(author_info.get("agentId") or "").strip()
-
- group_id = str(payload.get("groupId") or "").strip()
+ 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)
- )
-
+ 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,
+ 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 = f"{target_kind}:{target_id}"
+ delay_key = seen_key
if was_mentioned:
- await self._flush_delayed_entries(
- key=delay_key,
- target_id=target_id,
- target_kind=target_kind,
- reason="mention",
- entry=entry,
- )
+ await self._flush_delayed_entries(delay_key, target_id, target_kind, "mention", entry)
else:
- await self._enqueue_delayed_entry(
- key=delay_key,
- target_id=target_id,
- target_kind=target_kind,
- entry=entry,
- )
+ await self._enqueue_delayed_entry(delay_key, target_id, target_kind, entry)
return
- await self._dispatch_entries(
- target_id=target_id,
- target_kind=target_kind,
- entries=[entry],
- was_mentioned=was_mentioned,
- )
+ 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:
- removed = seen_queue.popleft()
- seen_set.discard(removed)
-
+ 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:
+ 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)
- )
+ 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=key,
- target_id=target_id,
- target_kind=target_kind,
- reason="timer",
- entry=None,
- )
+ 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:
+ 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
- elif state.timer is current:
- state.timer = None
-
+ 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
-
- await self._dispatch_entries(
- target_id=target_id,
- target_kind=target_kind,
- entries=entries,
- was_mentioned=(reason == "mention"),
- )
-
- async def _dispatch_entries(
- self,
- target_id: str,
- target_kind: str,
- entries: list[MochatBufferedEntry],
- was_mentioned: bool,
- ) -> None:
- if not entries:
- return
-
- is_group = bool(entries[-1].group_id)
- body = build_buffered_body(entries, is_group)
- if not body:
- body = "[empty message]"
-
last = entries[-1]
- 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),
- }
-
+ 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=metadata,
+ 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()
- state.timer = None
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(payload.get("groupId") or "").strip()
- panel_id = str(payload.get("converseId") or payload.get("panelId") or "").strip()
+ 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
- synthetic_event = {
- "type": "message.add",
- "timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(),
- "payload": {
- "messageId": str(payload.get("_id") or payload.get("messageId") or ""),
- "author": str(payload.get("author") or ""),
- "authorInfo": payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {},
- "content": payload.get("content"),
- "meta": payload.get("meta") if isinstance(payload.get("meta"), dict) else {},
- "groupId": group_id,
- "converseId": panel_id,
- },
- }
- await self._process_inbound_event(
- target_id=panel_id,
- event=synthetic_event,
- target_kind="panel",
+ 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):
+ if not isinstance(payload, dict) or payload.get("type") != "message":
return
-
- if payload.get("type") != "message":
- return
-
detail = payload.get("payload")
if not isinstance(detail, dict):
return
-
- group_id = str(detail.get("groupId") or "").strip()
- if group_id:
+ if _str_field(detail, "groupId"):
return
-
- converse_id = str(detail.get("converseId") or "").strip()
+ 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(subscribe_new=self._ws_ready)
+ await self._refresh_sessions_directory(self._ws_ready)
session_id = self._session_by_converse.get(converse_id)
if not session_id:
return
- message_id = str(detail.get("messageId") or payload.get("_id") or "").strip()
- author = str(detail.get("messageAuthor") or "").strip()
- content = str(detail.get("messagePlainContent") or detail.get("messageSnippet") or "").strip()
-
- synthetic_event = {
- "type": "message.add",
- "timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(),
- "payload": {
- "messageId": message_id,
- "author": author,
- "content": content,
- "meta": {
- "source": "notify:chat.inbox.append",
- "converseId": converse_id,
- },
- "converseId": converse_id,
- },
- }
-
- await self._process_inbound_event(
- target_id=session_id,
- event=synthetic_event,
- target_kind="session",
+ 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:
+ if cursor < 0 or cursor < self._session_cursor.get(session_id, 0):
return
-
- previous = self._session_cursor.get(session_id, 0)
- if cursor < previous:
- return
-
self._session_cursor[session_id] = cursor
- self._schedule_cursor_save()
-
- def _schedule_cursor_save(self) -> None:
- if self._cursor_save_task and not self._cursor_save_task.done():
- return
-
- self._cursor_save_task = asyncio.create_task(self._save_cursor_debounced())
+ 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)
@@ -1088,140 +833,63 @@ class MochatChannel(BaseChannel):
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(f"Failed to read Mochat cursor file: {e}")
return
-
cursors = data.get("cursors") if isinstance(data, dict) else None
- if not isinstance(cursors, dict):
- return
-
- for session_id, cursor in cursors.items():
- if isinstance(session_id, str) and isinstance(cursor, int) and cursor >= 0:
- self._session_cursor[session_id] = cursor
+ 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:
- payload = {
- "schemaVersion": 1,
- "updatedAt": datetime.utcnow().isoformat(),
- "cursors": self._session_cursor,
- }
-
try:
self._state_dir.mkdir(parents=True, exist_ok=True)
- self._cursor_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", "utf-8")
+ 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(f"Failed to save Mochat cursor file: {e}")
- def _base_url(self) -> str:
- return self.config.base_url.strip().rstrip("/")
+ # ---- 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._base_url()}{path}"
- response = await self._http.post(
- url,
- headers={
- "Content-Type": "application/json",
- "X-Claw-Token": self.config.claw_token,
- },
- json=payload,
- )
-
- text = response.text
+ 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}: {text[:200]}")
-
- parsed: Any
+ raise RuntimeError(f"Mochat HTTP {response.status_code}: {response.text[:200]}")
try:
parsed = response.json()
except Exception:
- parsed = text
-
+ parsed = response.text
if isinstance(parsed, dict) and isinstance(parsed.get("code"), int):
if parsed["code"] != 200:
- message = str(parsed.get("message") or parsed.get("name") or "request failed")
- raise RuntimeError(f"Mochat API error: {message} (code={parsed['code']})")
+ 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 {}
- if isinstance(parsed, dict):
- return parsed
-
- return {}
-
- async def _watch_session(
- self,
- session_id: str,
- cursor: int,
- timeout_ms: int,
- limit: int,
- ) -> dict[str, Any]:
- return await self._post_json(
- "/api/claw/sessions/watch",
- {
- "sessionId": session_id,
- "cursor": cursor,
- "timeoutMs": timeout_ms,
- "limit": limit,
- },
- )
-
- async def _send_session_message(
- self,
- session_id: str,
- content: str,
- reply_to: str | None,
- ) -> dict[str, Any]:
- payload = {
- "sessionId": session_id,
- "content": content,
- }
+ 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:
- payload["replyTo"] = reply_to
- return await self._post_json("/api/claw/sessions/send", payload)
-
- async def _send_panel_message(
- self,
- panel_id: str,
- content: str,
- reply_to: str | None,
- group_id: str | None,
- ) -> dict[str, Any]:
- payload = {
- "panelId": panel_id,
- "content": content,
- }
- if reply_to:
- payload["replyTo"] = reply_to
+ body["replyTo"] = reply_to
if group_id:
- payload["groupId"] = group_id
- return await self._post_json("/api/claw/groups/panels/send", payload)
+ body["groupId"] = group_id
+ return await self._post_json(path, body)
- async def _list_sessions(self) -> dict[str, Any]:
- return await self._post_json("/api/claw/sessions/list", {})
-
- async def _get_workspace_group(self) -> dict[str, Any]:
- return await self._post_json("/api/claw/groups/get", {})
-
- async def _list_panel_messages(self, panel_id: str, limit: int) -> dict[str, Any]:
- return await self._post_json(
- "/api/claw/groups/panels/messages",
- {
- "panelId": panel_id,
- "limit": limit,
- },
- )
-
- def _read_group_id(self, metadata: dict[str, Any]) -> str | None:
+ @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")
- if isinstance(value, str) and value.strip():
- return value.strip()
- return None
+ return value.strip() if isinstance(value, str) and value.strip() else None
From cd4eeb1d204c3355684e192a7e055a2716249300 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 07:22:03 +0000
Subject: [PATCH 056/506] docs: update mochat guidelines
---
README.md | 51 +++++++++++++++++++++++++++++++++------------------
1 file changed, 33 insertions(+), 18 deletions(-)
diff --git a/README.md b/README.md
index 0c74e17..b749b31 100644
--- a/README.md
+++ b/README.md
@@ -221,40 +221,55 @@ nanobot gateway
Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
-**1. Prepare credentials**
-- `clawToken`: Claw API token
-- `agentUserId`: your bot user id
-- Optional: `sessions`/`panels` with `["*"]` for auto-discovery
+**1. Ask nanobot to set up Mochat for you**
-**2. Configure**
+Simply send this message to nanobot (replace `xxx@xxx` with your real email):
+
+```
+Read https://raw.githubusercontent.com/HKUDS/MoChat/refs/heads/main/skills/nanobot/skill.md and register on MoChat. My Email account is xxx@xxx Bind me as your owner and DM me on MoChat.
+```
+
+nanobot will automatically register, configure `~/.nanobot/config.json`, and connect to Mochat.
+
+**2. Restart gateway**
+
+```bash
+nanobot gateway
+```
+
+That's it — nanobot handles the rest!
+
+
+
+
+Manual configuration (advanced)
+
+If you prefer to configure manually, add the following to `~/.nanobot/config.json`:
+
+> Keep `claw_token` private. It should only be sent in `X-Claw-Token` header to your Mochat API endpoint.
```json
{
"channels": {
"mochat": {
"enabled": true,
- "baseUrl": "https://mochat.io",
- "socketUrl": "https://mochat.io",
- "socketPath": "/socket.io",
- "clawToken": "claw_xxx",
- "agentUserId": "6982abcdef",
+ "base_url": "https://mochat.io",
+ "socket_url": "https://mochat.io",
+ "socket_path": "/socket.io",
+ "claw_token": "claw_xxx",
+ "agent_user_id": "6982abcdef",
"sessions": ["*"],
"panels": ["*"],
- "replyDelayMode": "non-mention",
- "replyDelayMs": 120000
+ "reply_delay_mode": "non-mention",
+ "reply_delay_ms": 120000
}
}
}
```
-**3. Run**
-```bash
-nanobot gateway
-```
-> [!TIP]
-> Keep `clawToken` private. It should only be sent in `X-Claw-Token` header to your Mochat API endpoint.
+
From 8626caff742c6e68f6fc4a951bc2ec8d9ab2424b Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 07:39:15 +0000
Subject: [PATCH 057/506] fix: prevent safety guard from blocking relative
paths in exec tool
---
nanobot/agent/tools/shell.py | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index 143d187..18eff64 100644
--- a/nanobot/agent/tools/shell.py
+++ b/nanobot/agent/tools/shell.py
@@ -128,14 +128,17 @@ class ExecTool(Tool):
cwd_path = Path(cwd).resolve()
win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd)
- posix_paths = re.findall(r"/[^\s\"']+", 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).resolve()
+ p = Path(raw.strip()).resolve()
except Exception:
continue
- if cwd_path not in p.parents and p != cwd_path:
+ 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)"
return None
From ef1b062be5dd3f4af5cc3888d84ac73d93c98a3e Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 07:42:39 +0000
Subject: [PATCH 058/506] fix: create skills dir on onboard
---
nanobot/cli/commands.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index bcadba9..a200e67 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -294,6 +294,10 @@ This file stores important information that should persist across sessions.
""")
console.print(" [dim]Created memory/MEMORY.md[/dim]")
+ # Create skills directory for custom user skills
+ skills_dir = workspace / "skills"
+ skills_dir.mkdir(exist_ok=True)
+
def _make_provider(config):
"""Create LiteLLMProvider from config. Exits if no API key found."""
From c98ca70d3041c2b802fcffa0d29eb5dd4245ab3b Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 08:38:36 +0000
Subject: [PATCH 059/506] docs: update provider tips
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index b749b31..744361a 100644
--- a/README.md
+++ b/README.md
@@ -95,7 +95,7 @@ pip install nanobot-ai
> [!TIP]
> Set your API key in `~/.nanobot/config.json`.
-> Get API keys: [OpenRouter](https://openrouter.ai/keys) (Global) · [DashScope](https://dashscope.console.aliyun.com) (Qwen) · [Brave Search](https://brave.com/search/api/) (optional, for web search)
+> Get API keys: [OpenRouter](https://openrouter.ai/keys) (Global) · [Brave Search](https://brave.com/search/api/) (optional, for web search)
**1. Initialize**
From 08b9270e0accb85fc380bda71d9cbed345ec5776 Mon Sep 17 00:00:00 2001
From: chaohuang-ai
Date: Tue, 10 Feb 2026 19:50:09 +0800
Subject: [PATCH 060/506] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 744361a..964c81e 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## 📢 News
-- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check the [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
+- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check out the latest updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
From 9ee65cd681fc35fed94b6706edda02a183e6a7b9 Mon Sep 17 00:00:00 2001
From: chaohuang-ai
Date: Tue, 10 Feb 2026 19:50:47 +0800
Subject: [PATCH 061/506] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 964c81e..6789246 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## 📢 News
-- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check out the latest updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
+- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check out the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
From ca7d6bf1abef9a203f4e3c67a9fd6cc5c02ec815 Mon Sep 17 00:00:00 2001
From: chaohuang-ai
Date: Tue, 10 Feb 2026 19:51:12 +0800
Subject: [PATCH 062/506] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 6789246..3e70a71 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## 📢 News
-- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check out the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
+- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
From eca16947be98547ac180b2cd56b782464997a1f7 Mon Sep 17 00:00:00 2001
From: chaohuang-ai
Date: Tue, 10 Feb 2026 19:51:46 +0800
Subject: [PATCH 063/506] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 3e70a71..01643b1 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## 📢 News
-- **2026-02-10** 🎉 Released v0.1.3.post6 with multiple improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
+- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
From f8de53c7c1d62ba42944f8dc905f829d2b6a8d04 Mon Sep 17 00:00:00 2001
From: chaohuang-ai
Date: Tue, 10 Feb 2026 20:46:13 +0800
Subject: [PATCH 064/506] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 01643b1..4a26a2f 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-🐈 **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [Clawdbot](https://github.com/openclaw/openclaw)
+🐈 **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw)
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
From 19b19d0d4ab72ec23d8644bb10f8d47d07ce0a70 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Feb 2026 16:35:50 +0000
Subject: [PATCH 065/506] docs: update minimax tips
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index ce36168..1da4a41 100644
--- a/README.md
+++ b/README.md
@@ -582,6 +582,7 @@ Config file: `~/.nanobot/config.json`
> [!TIP]
> - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
+> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
From 3561b6a63d255d2c0a2004a94c6f3e02bafd1b60 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?=
Date: Wed, 11 Feb 2026 10:23:58 +0800
Subject: [PATCH 066/506] feat(cli): rewrite input layer with prompt_toolkit
and polish UI
- Replaces fragile input() hacks with robust prompt_toolkit.PromptSession
- Native support for multiline paste, history, and clean display
- Restores animated spinner in _thinking_ctx (now safe)
- Replaces boxed Panel with clean header for easier copying
- Adds prompt-toolkit dependency
- Adds new unit tests for input layer
---
nanobot/cli/commands.py | 110 +++++++++++++---------------------------
pyproject.toml | 1 +
tests/test_cli_input.py | 58 +++++++++++++++++++++
3 files changed, 95 insertions(+), 74 deletions(-)
create mode 100644 tests/test_cli_input.py
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index a200e67..95aaeb4 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -1,7 +1,6 @@
"""CLI commands for nanobot."""
import asyncio
-import atexit
import os
import signal
from pathlib import Path
@@ -15,6 +14,11 @@ from rich.panel import Panel
from rich.table import Table
from rich.text import Text
+from prompt_toolkit import PromptSession
+from prompt_toolkit.formatted_text import HTML
+from prompt_toolkit.history import FileHistory
+from prompt_toolkit.patch_stdout import patch_stdout
+
from nanobot import __version__, __logo__
app = typer.Typer(
@@ -27,13 +31,10 @@ console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
# ---------------------------------------------------------------------------
-# Lightweight CLI input: readline for arrow keys / history, termios for flush
+# CLI input: prompt_toolkit for editing, paste, history, and display
# ---------------------------------------------------------------------------
-_READLINE = None
-_HISTORY_FILE: Path | None = None
-_HISTORY_HOOK_REGISTERED = False
-_USING_LIBEDIT = False
+_PROMPT_SESSION: PromptSession | None = None
_SAVED_TERM_ATTRS = None # original termios settings, restored on exit
@@ -64,15 +65,6 @@ def _flush_pending_tty_input() -> None:
return
-def _save_history() -> None:
- if _READLINE is None or _HISTORY_FILE is None:
- return
- try:
- _READLINE.write_history_file(str(_HISTORY_FILE))
- except Exception:
- return
-
-
def _restore_terminal() -> None:
"""Restore terminal to its original state (echo, line buffering, etc.)."""
if _SAVED_TERM_ATTRS is None:
@@ -84,11 +76,11 @@ def _restore_terminal() -> None:
pass
-def _enable_line_editing() -> None:
- """Enable readline for arrow keys, line editing, and persistent history."""
- global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT, _SAVED_TERM_ATTRS
+def _init_prompt_session() -> None:
+ """Create the prompt_toolkit session with persistent file history."""
+ global _PROMPT_SESSION, _SAVED_TERM_ATTRS
- # Save terminal state before readline touches it
+ # Save terminal state so we can restore it on exit
try:
import termios
_SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno())
@@ -97,59 +89,22 @@ def _enable_line_editing() -> None:
history_file = Path.home() / ".nanobot" / "history" / "cli_history"
history_file.parent.mkdir(parents=True, exist_ok=True)
- _HISTORY_FILE = history_file
- try:
- import readline
- except ImportError:
- return
-
- _READLINE = readline
- _USING_LIBEDIT = "libedit" in (readline.__doc__ or "").lower()
-
- try:
- if _USING_LIBEDIT:
- readline.parse_and_bind("bind ^I rl_complete")
- else:
- readline.parse_and_bind("tab: complete")
- readline.parse_and_bind("set editing-mode emacs")
- except Exception:
- pass
-
- try:
- readline.read_history_file(str(history_file))
- except Exception:
- pass
-
- if not _HISTORY_HOOK_REGISTERED:
- atexit.register(_save_history)
- _HISTORY_HOOK_REGISTERED = True
-
-
-def _prompt_text() -> str:
- """Build a readline-friendly colored prompt."""
- if _READLINE is None:
- return "You: "
- # libedit on macOS does not honor GNU readline non-printing markers.
- if _USING_LIBEDIT:
- return "\033[1;34mYou:\033[0m "
- return "\001\033[1;34m\002You:\001\033[0m\002 "
+ _PROMPT_SESSION = PromptSession(
+ 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."""
+ """Render assistant response with clean, copy-friendly header."""
content = response or ""
body = Markdown(content) if render_markdown else Text(content)
console.print()
- console.print(
- Panel(
- body,
- title=f"{__logo__} nanobot",
- title_align="left",
- border_style="cyan",
- padding=(0, 1),
- )
- )
+ # Use a simple header instead of a Panel box, making it easier to copy text
+ console.print(f"{__logo__} [bold cyan]nanobot[/bold cyan]")
+ console.print(body)
console.print()
@@ -159,13 +114,25 @@ def _is_exit_command(command: str) -> bool:
async def _read_interactive_input_async() -> str:
- """Read user input with arrow keys and history (runs input() in a thread)."""
+ """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:
- return await asyncio.to_thread(input, _prompt_text())
+ with patch_stdout():
+ return await _PROMPT_SESSION.prompt_async(
+ HTML("You: "),
+ )
except EOFError as exc:
raise KeyboardInterrupt from exc
+
def version_callback(value: bool):
if value:
console.print(f"{__logo__} nanobot v{__version__}")
@@ -473,6 +440,7 @@ def agent(
if logs:
from contextlib import nullcontext
return nullcontext()
+ # Animated spinner is safe to use with prompt_toolkit input handling
return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
if message:
@@ -485,13 +453,10 @@ def agent(
asyncio.run(run_once())
else:
# Interactive mode
- _enable_line_editing()
+ _init_prompt_session()
console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n")
- # input() runs in a worker thread that can't be cancelled.
- # Without this handler, asyncio.run() would hang waiting for it.
def _exit_on_sigint(signum, frame):
- _save_history()
_restore_terminal()
console.print("\nGoodbye!")
os._exit(0)
@@ -508,7 +473,6 @@ def agent(
continue
if _is_exit_command(command):
- _save_history()
_restore_terminal()
console.print("\nGoodbye!")
break
@@ -517,12 +481,10 @@ def agent(
response = await agent_loop.process_direct(user_input, session_id)
_print_agent_response(response, render_markdown=markdown)
except KeyboardInterrupt:
- _save_history()
_restore_terminal()
console.print("\nGoodbye!")
break
except EOFError:
- _save_history()
_restore_terminal()
console.print("\nGoodbye!")
break
diff --git a/pyproject.toml b/pyproject.toml
index 3c2fec9..b1b3c81 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,6 +37,7 @@ dependencies = [
"slack-sdk>=3.26.0",
"qq-botpy>=1.0.0",
"python-socks[asyncio]>=2.4.0",
+ "prompt-toolkit>=3.0.0",
]
[project.optional-dependencies]
diff --git a/tests/test_cli_input.py b/tests/test_cli_input.py
new file mode 100644
index 0000000..6f9c257
--- /dev/null
+++ b/tests/test_cli_input.py
@@ -0,0 +1,58 @@
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from prompt_toolkit.formatted_text import HTML
+
+from nanobot.cli import commands
+
+
+@pytest.fixture
+def mock_prompt_session():
+ """Mock the global prompt session."""
+ mock_session = MagicMock()
+ mock_session.prompt_async = AsyncMock()
+ with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session):
+ yield mock_session
+
+
+@pytest.mark.asyncio
+async def test_read_interactive_input_async_returns_input(mock_prompt_session):
+ """Test that _read_interactive_input_async returns the user input from prompt_session."""
+ mock_prompt_session.prompt_async.return_value = "hello world"
+
+ result = await commands._read_interactive_input_async()
+
+ assert result == "hello world"
+ mock_prompt_session.prompt_async.assert_called_once()
+ args, _ = mock_prompt_session.prompt_async.call_args
+ assert isinstance(args[0], HTML) # Verify HTML prompt is used
+
+
+@pytest.mark.asyncio
+async def test_read_interactive_input_async_handles_eof(mock_prompt_session):
+ """Test that EOFError converts to KeyboardInterrupt."""
+ mock_prompt_session.prompt_async.side_effect = EOFError()
+
+ with pytest.raises(KeyboardInterrupt):
+ await commands._read_interactive_input_async()
+
+
+def test_init_prompt_session_creates_session():
+ """Test that _init_prompt_session initializes the global session."""
+ # Ensure global is None before test
+ commands._PROMPT_SESSION = None
+
+ with patch("nanobot.cli.commands.PromptSession") as MockSession, \
+ patch("nanobot.cli.commands.FileHistory") as MockHistory, \
+ patch("pathlib.Path.home") as mock_home:
+
+ mock_home.return_value = MagicMock()
+
+ commands._init_prompt_session()
+
+ assert commands._PROMPT_SESSION is not None
+ MockSession.assert_called_once()
+ _, kwargs = MockSession.call_args
+ assert kwargs["multiline"] is False
+ assert kwargs["enable_open_in_editor"] is False
From 33930d1265496027e9a73b9ec1b3528df3f93bda Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?=
Date: Wed, 11 Feb 2026 10:39:35 +0800
Subject: [PATCH 067/506] feat(cli): revert panel removal (keep frame),
preserve input rewrite
---
nanobot/cli/commands.py | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 95aaeb4..d8e48e1 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -98,13 +98,19 @@ def _init_prompt_session() -> None:
def _print_agent_response(response: str, render_markdown: bool) -> None:
- """Render assistant response with clean, copy-friendly header."""
+ """Render assistant response with consistent terminal styling."""
content = response or ""
body = Markdown(content) if render_markdown else Text(content)
console.print()
- # Use a simple header instead of a Panel box, making it easier to copy text
- console.print(f"{__logo__} [bold cyan]nanobot[/bold cyan]")
- console.print(body)
+ console.print(
+ Panel(
+ body,
+ title=f"{__logo__} nanobot",
+ title_align="left",
+ border_style="cyan",
+ padding=(0, 1),
+ )
+ )
console.print()
From 9d304d8a41a0c495821bc28fe123ace6eca77082 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Feb 2026 09:37:49 +0000
Subject: [PATCH 068/506] refactor: remove Panel border from CLI output for
cleaner copy-paste
---
nanobot/cli/commands.py | 12 ++----------
1 file changed, 2 insertions(+), 10 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index d8e48e1..aa99d55 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -10,7 +10,6 @@ import sys
import typer
from rich.console import Console
from rich.markdown import Markdown
-from rich.panel import Panel
from rich.table import Table
from rich.text import Text
@@ -102,15 +101,8 @@ def _print_agent_response(response: str, render_markdown: bool) -> None:
content = response or ""
body = Markdown(content) if render_markdown else Text(content)
console.print()
- console.print(
- Panel(
- body,
- title=f"{__logo__} nanobot",
- title_align="left",
- border_style="cyan",
- padding=(0, 1),
- )
- )
+ console.print(f"[cyan]{__logo__} nanobot[/cyan]")
+ console.print(body)
console.print()
From cbab72ab726ba9c18c0e6494679ea394072930d4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?=
Date: Wed, 11 Feb 2026 13:01:29 +0100
Subject: [PATCH 069/506] fix: pydantic deprecation configdict
---
nanobot/config/schema.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index f6c861d..19feba4 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -1,7 +1,7 @@
"""Configuration schema using Pydantic."""
from pathlib import Path
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, ConfigDict
from pydantic_settings import BaseSettings
@@ -281,6 +281,7 @@ class Config(BaseSettings):
return spec.default_api_base
return None
- class Config:
- env_prefix = "NANOBOT_"
- env_nested_delimiter = "__"
+ model_config = ConfigDict(
+ env_prefix="NANOBOT_",
+ env_nested_delimiter="__"
+ )
From 554ba81473668576af939a1720c36ee7630ee72a Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Feb 2026 14:39:20 +0000
Subject: [PATCH 070/506] docs: update agent community tips
---
README.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/README.md b/README.md
index 1da4a41..fed25c8 100644
--- a/README.md
+++ b/README.md
@@ -573,6 +573,17 @@ nanobot gateway
+## 🌐 Agent Social Network
+
+🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!**
+
+| Platform | How to Join (send this message to your bot) |
+|----------|-------------|
+| [**Moltbook**](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` |
+| [**ClawdChat**](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` |
+
+Simply send the command above to your nanobot (via CLI or any chat channel), and it will handle the rest.
+
## ⚙️ Configuration
Config file: `~/.nanobot/config.json`
From b429bf9381d22edd4a5d35eb720cd2704eae63b5 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Feb 2026 01:20:57 +0000
Subject: [PATCH 071/506] fix: improve long-running stability for various
channels
---
nanobot/channels/dingtalk.py | 11 +++++++++--
nanobot/channels/feishu.py | 13 ++++++++-----
nanobot/channels/qq.py | 15 +++++++++------
nanobot/channels/telegram.py | 11 +++++++++--
4 files changed, 35 insertions(+), 15 deletions(-)
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 72d3afd..4a8cdd9 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -137,8 +137,15 @@ class DingTalkChannel(BaseChannel):
logger.info("DingTalk bot started with Stream Mode")
- # client.start() is an async infinite loop handling the websocket connection
- await self._client.start()
+ # Reconnect loop: restart stream if SDK exits or crashes
+ while self._running:
+ try:
+ await self._client.start()
+ except Exception as e:
+ logger.warning(f"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(f"Failed to start DingTalk channel: {e}")
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 1c176a2..23d1415 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -98,12 +98,15 @@ class FeishuChannel(BaseChannel):
log_level=lark.LogLevel.INFO
)
- # Start WebSocket client in a separate thread
+ # Start WebSocket client in a separate thread with reconnect loop
def run_ws():
- try:
- self._ws_client.start()
- except Exception as e:
- logger.error(f"Feishu WebSocket error: {e}")
+ while self._running:
+ try:
+ self._ws_client.start()
+ except Exception as e:
+ logger.warning(f"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()
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index 5964d30..0e8fe66 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -75,12 +75,15 @@ class QQChannel(BaseChannel):
logger.info("QQ bot started (C2C private message)")
async def _run_bot(self) -> None:
- """Run the bot connection."""
- try:
- await self._client.start(appid=self.config.app_id, secret=self.config.secret)
- except Exception as e:
- logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}")
- self._running = False
+ """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(f"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."""
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index ff46c86..1abd600 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
from loguru import logger
from telegram import BotCommand, Update
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
@@ -121,11 +122,13 @@ class TelegramChannel(BaseChannel):
self._running = True
- # Build the application
- builder = Application.builder().token(self.config.token)
+ # 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))
@@ -386,6 +389,10 @@ class TelegramChannel(BaseChannel):
except Exception as e:
logger.debug(f"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(f"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:
From a66fa650a1e711d1c618d5a688315e1e40ecd503 Mon Sep 17 00:00:00 2001
From: 3927o <1624497311@qq.com>
Date: Thu, 12 Feb 2026 09:38:59 +0800
Subject: [PATCH 072/506] feat(cron): add 'at' parameter for one-time scheduled
tasks
---
nanobot/agent/tools/cron.py | 19 ++++++++++++++++---
1 file changed, 16 insertions(+), 3 deletions(-)
diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py
index ec0d2cd..9f1ecdb 100644
--- a/nanobot/agent/tools/cron.py
+++ b/nanobot/agent/tools/cron.py
@@ -50,6 +50,10 @@ class CronTool(Tool):
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
},
+ "at": {
+ "type": "string",
+ "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
+ },
"job_id": {
"type": "string",
"description": "Job ID (for remove)"
@@ -64,30 +68,38 @@ class CronTool(Tool):
message: str = "",
every_seconds: int | None = None,
cron_expr: str | None = None,
+ at: str | None = None,
job_id: str | None = None,
**kwargs: Any
) -> str:
if action == "add":
- return self._add_job(message, every_seconds, cron_expr)
+ return self._add_job(message, every_seconds, cron_expr, at)
elif action == "list":
return self._list_jobs()
elif action == "remove":
return self._remove_job(job_id)
return f"Unknown action: {action}"
- def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None) -> str:
+ def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str:
if not message:
return "Error: message is required for add"
if not self._channel or not self._chat_id:
return "Error: no session context (channel/chat_id)"
# Build schedule
+ 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)
+ elif at:
+ from datetime import datetime
+ 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 or cron_expr is required"
+ return "Error: either every_seconds, cron_expr, or at is required"
job = self._cron.add_job(
name=message[:30],
@@ -96,6 +108,7 @@ class CronTool(Tool):
deliver=True,
channel=self._channel,
to=self._chat_id,
+ delete_after_run=delete_after,
)
return f"Created job '{job.name}' (id: {job.id})"
From d3354942120d2b7bfcdf38da152397b31ae6b53d Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Feb 2026 06:25:25 +0000
Subject: [PATCH 073/506] feat: add interleaved chain-of-thought to agent loop
---
nanobot/agent/context.py | 2 +-
nanobot/agent/loop.py | 4 ++++
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index d807854..543e2f0 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -103,7 +103,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
-Always be helpful, accurate, and concise. When using tools, explain what you're doing.
+Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
When remembering something, write to {workspace_path}/memory/MEMORY.md"""
def _load_bootstrap_files(self) -> str:
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index b764c3d..46a31bd 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -225,6 +225,8 @@ class AgentLoop:
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
+ # Interleaved CoT: reflect before next action
+ messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
# No tool calls, we're done
final_content = response.content
@@ -330,6 +332,8 @@ class AgentLoop:
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
+ # Interleaved CoT: reflect before next action
+ messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
final_content = response.content
break
From 7087947e0ee6fb3fd74808de923cc5ef0e9b5d8b Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Feb 2026 06:50:44 +0000
Subject: [PATCH 074/506] feat(cron): add one-time 'at' schedule to skill docs
and show timezone in system prompt
---
nanobot/agent/context.py | 4 +++-
nanobot/skills/cron/SKILL.md | 9 ++++++++-
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 543e2f0..b9c0790 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -73,7 +73,9 @@ Skills with available="false" need dependencies installed first - you can try in
def _get_identity(self) -> str:
"""Get the core identity section."""
from datetime import datetime
+ import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
+ tz = _time.strftime("%Z") or "UTC"
workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
@@ -88,7 +90,7 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
- Spawn subagents for complex background tasks
## Current Time
-{now}
+{now} ({tz})
## Runtime
{runtime}
diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md
index c8beecb..7db25d8 100644
--- a/nanobot/skills/cron/SKILL.md
+++ b/nanobot/skills/cron/SKILL.md
@@ -7,10 +7,11 @@ description: Schedule reminders and recurring tasks.
Use the `cron` tool to schedule reminders or recurring tasks.
-## Two Modes
+## Three Modes
1. **Reminder** - message is sent directly to user
2. **Task** - message is a task description, agent executes and sends result
+3. **One-time** - runs once at a specific time, then auto-deletes
## Examples
@@ -24,6 +25,11 @@ Dynamic task (agent executes each time):
cron(action="add", message="Check HKUDS/nanobot GitHub stars and report", every_seconds=600)
```
+One-time scheduled task (compute ISO datetime from current time):
+```
+cron(action="add", message="Remind me about the meeting", at="")
+```
+
List/remove:
```
cron(action="list")
@@ -38,3 +44,4 @@ cron(action="remove", job_id="abc123")
| every hour | every_seconds: 3600 |
| every day at 8am | cron_expr: "0 8 * * *" |
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
+| at a specific time | at: ISO datetime string (compute from current time) |
From de3324807fd5d62a3fb83dba020cd1afc8e7f867 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Feb 2026 07:49:36 +0000
Subject: [PATCH 075/506] fix(subagent): add edit_file tool and time context to
sub agent
---
README.md | 2 +-
nanobot/agent/subagent.py | 15 +++++++++++----
2 files changed, 12 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index fed25c8..ea606de 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 6113efb..9e0cd7c 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -12,7 +12,7 @@ from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.agent.tools.registry import ToolRegistry
-from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, ListDirTool
+from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
@@ -101,6 +101,7 @@ class SubagentManager:
allowed_dir = self.workspace if self.restrict_to_workspace else None
tools.register(ReadFileTool(allowed_dir=allowed_dir))
tools.register(WriteFileTool(allowed_dir=allowed_dir))
+ tools.register(EditFileTool(allowed_dir=allowed_dir))
tools.register(ListDirTool(allowed_dir=allowed_dir))
tools.register(ExecTool(
working_dir=str(self.workspace),
@@ -210,12 +211,17 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
def _build_subagent_prompt(self, task: str) -> str:
"""Build a focused system prompt for the subagent."""
+ from datetime import datetime
+ import time as _time
+ now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
+ tz = _time.strftime("%Z") or "UTC"
+
return f"""# Subagent
-You are a subagent spawned by the main agent to complete a specific task.
+## Current Time
+{now} ({tz})
-## Your Task
-{task}
+You are a subagent spawned by the main agent to complete a specific task.
## Rules
1. Stay focused - complete only the assigned task, nothing else
@@ -236,6 +242,7 @@ You are a subagent spawned by the main agent to complete a specific task.
## Workspace
Your workspace is at: {self.workspace}
+Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
When you have completed the task, provide a clear summary of your findings or actions."""
From cb5964c20149b089f11913b9d33eb8dbe62878ff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?=
Date: Thu, 12 Feb 2026 10:01:30 +0100
Subject: [PATCH 076/506] feat(tools): add mcp support
---
README.md | 2 +-
nanobot/agent/context.py | 6 ++-
nanobot/agent/loop.py | 30 +++++++++++++
nanobot/agent/subagent.py | 15 +++++--
nanobot/agent/tools/cron.py | 19 +++++++--
nanobot/agent/tools/mcp.py | 82 ++++++++++++++++++++++++++++++++++++
nanobot/channels/dingtalk.py | 11 ++++-
nanobot/channels/feishu.py | 13 +++---
nanobot/channels/qq.py | 15 ++++---
nanobot/channels/telegram.py | 11 ++++-
nanobot/cli/commands.py | 3 ++
nanobot/config/schema.py | 18 ++++++--
nanobot/skills/cron/SKILL.md | 9 +++-
pyproject.toml | 1 +
14 files changed, 205 insertions(+), 30 deletions(-)
create mode 100644 nanobot/agent/tools/mcp.py
diff --git a/README.md b/README.md
index fed25c8..ea606de 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index d807854..b9c0790 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -73,7 +73,9 @@ Skills with available="false" need dependencies installed first - you can try in
def _get_identity(self) -> str:
"""Get the core identity section."""
from datetime import datetime
+ import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
+ tz = _time.strftime("%Z") or "UTC"
workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
@@ -88,7 +90,7 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
- Spawn subagents for complex background tasks
## Current Time
-{now}
+{now} ({tz})
## Runtime
{runtime}
@@ -103,7 +105,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
-Always be helpful, accurate, and concise. When using tools, explain what you're doing.
+Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
When remembering something, write to {workspace_path}/memory/MEMORY.md"""
def _load_bootstrap_files(self) -> str:
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index b764c3d..a3ab678 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -1,6 +1,7 @@
"""Agent loop: the core processing engine."""
import asyncio
+from contextlib import AsyncExitStack
import json
from pathlib import Path
from typing import Any
@@ -46,6 +47,7 @@ class AgentLoop:
cron_service: "CronService | None" = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
+ mcp_servers: dict | None = None,
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
@@ -73,6 +75,9 @@ class AgentLoop:
)
self._running = False
+ self._mcp_servers = mcp_servers or {}
+ self._mcp_stack: AsyncExitStack | None = None
+ self._mcp_connected = False
self._register_default_tools()
def _register_default_tools(self) -> None:
@@ -107,9 +112,20 @@ class AgentLoop:
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
+ async def _connect_mcp(self) -> None:
+ """Connect to configured MCP servers (one-time, lazy)."""
+ if self._mcp_connected or not self._mcp_servers:
+ return
+ self._mcp_connected = True
+ from nanobot.agent.tools.mcp import connect_mcp_servers
+ self._mcp_stack = AsyncExitStack()
+ await self._mcp_stack.__aenter__()
+ await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
+
async def run(self) -> None:
"""Run the agent loop, processing messages from the bus."""
self._running = True
+ await self._connect_mcp()
logger.info("Agent loop started")
while self._running:
@@ -136,6 +152,15 @@ class AgentLoop:
except asyncio.TimeoutError:
continue
+ async def _close_mcp(self) -> None:
+ """Close MCP connections."""
+ if self._mcp_stack:
+ try:
+ await self._mcp_stack.aclose()
+ except (RuntimeError, BaseExceptionGroup):
+ pass # MCP SDK cancel scope cleanup is noisy but harmless
+ self._mcp_stack = None
+
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
@@ -225,6 +250,8 @@ class AgentLoop:
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
+ # Interleaved CoT: reflect before next action
+ messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
# No tool calls, we're done
final_content = response.content
@@ -330,6 +357,8 @@ class AgentLoop:
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
+ # Interleaved CoT: reflect before next action
+ messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
final_content = response.content
break
@@ -367,6 +396,7 @@ class AgentLoop:
Returns:
The agent's response.
"""
+ await self._connect_mcp()
msg = InboundMessage(
channel=channel,
sender_id="user",
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 6113efb..9e0cd7c 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -12,7 +12,7 @@ from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.agent.tools.registry import ToolRegistry
-from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, ListDirTool
+from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
@@ -101,6 +101,7 @@ class SubagentManager:
allowed_dir = self.workspace if self.restrict_to_workspace else None
tools.register(ReadFileTool(allowed_dir=allowed_dir))
tools.register(WriteFileTool(allowed_dir=allowed_dir))
+ tools.register(EditFileTool(allowed_dir=allowed_dir))
tools.register(ListDirTool(allowed_dir=allowed_dir))
tools.register(ExecTool(
working_dir=str(self.workspace),
@@ -210,12 +211,17 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
def _build_subagent_prompt(self, task: str) -> str:
"""Build a focused system prompt for the subagent."""
+ from datetime import datetime
+ import time as _time
+ now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
+ tz = _time.strftime("%Z") or "UTC"
+
return f"""# Subagent
-You are a subagent spawned by the main agent to complete a specific task.
+## Current Time
+{now} ({tz})
-## Your Task
-{task}
+You are a subagent spawned by the main agent to complete a specific task.
## Rules
1. Stay focused - complete only the assigned task, nothing else
@@ -236,6 +242,7 @@ You are a subagent spawned by the main agent to complete a specific task.
## Workspace
Your workspace is at: {self.workspace}
+Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
When you have completed the task, provide a clear summary of your findings or actions."""
diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py
index ec0d2cd..9f1ecdb 100644
--- a/nanobot/agent/tools/cron.py
+++ b/nanobot/agent/tools/cron.py
@@ -50,6 +50,10 @@ class CronTool(Tool):
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
},
+ "at": {
+ "type": "string",
+ "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
+ },
"job_id": {
"type": "string",
"description": "Job ID (for remove)"
@@ -64,30 +68,38 @@ class CronTool(Tool):
message: str = "",
every_seconds: int | None = None,
cron_expr: str | None = None,
+ at: str | None = None,
job_id: str | None = None,
**kwargs: Any
) -> str:
if action == "add":
- return self._add_job(message, every_seconds, cron_expr)
+ return self._add_job(message, every_seconds, cron_expr, at)
elif action == "list":
return self._list_jobs()
elif action == "remove":
return self._remove_job(job_id)
return f"Unknown action: {action}"
- def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None) -> str:
+ def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str:
if not message:
return "Error: message is required for add"
if not self._channel or not self._chat_id:
return "Error: no session context (channel/chat_id)"
# Build schedule
+ 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)
+ elif at:
+ from datetime import datetime
+ 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 or cron_expr is required"
+ return "Error: either every_seconds, cron_expr, or at is required"
job = self._cron.add_job(
name=message[:30],
@@ -96,6 +108,7 @@ class CronTool(Tool):
deliver=True,
channel=self._channel,
to=self._chat_id,
+ delete_after_run=delete_after,
)
return f"Created job '{job.name}' (id: {job.id})"
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
new file mode 100644
index 0000000..bcef4aa
--- /dev/null
+++ b/nanobot/agent/tools/mcp.py
@@ -0,0 +1,82 @@
+"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
+
+from contextlib import AsyncExitStack
+from typing import Any
+
+from loguru import logger
+
+from nanobot.agent.tools.base import Tool
+from nanobot.agent.tools.registry import ToolRegistry
+
+
+class MCPToolWrapper(Tool):
+ """Wraps a single MCP server tool as a nanobot Tool."""
+
+ def __init__(self, session, server_name: str, tool_def):
+ self._session = session
+ self._server = server_name
+ 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": {}}
+
+ @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
+ result = await self._session.call_tool(
+ self._name.removeprefix(f"mcp_{self._server}_"), arguments=kwargs
+ )
+ parts = []
+ for block in result.content:
+ if isinstance(block, types.TextContent):
+ parts.append(block.text)
+ else:
+ parts.append(str(block))
+ return "\n".join(parts) or "(no output)"
+
+
+async def connect_mcp_servers(
+ mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
+) -> None:
+ """Connect to configured MCP servers and register their tools."""
+ from mcp import ClientSession, StdioServerParameters
+ from mcp.client.stdio import stdio_client
+
+ for name, cfg in mcp_servers.items():
+ try:
+ if cfg.command:
+ params = StdioServerParameters(
+ command=cfg.command, args=cfg.args, env=cfg.env or None
+ )
+ read, write = await stack.enter_async_context(stdio_client(params))
+ elif cfg.url:
+ from mcp.client.streamable_http import streamable_http_client
+ read, write, _ = await stack.enter_async_context(
+ streamable_http_client(cfg.url)
+ )
+ else:
+ logger.warning(f"MCP server '{name}': no command or url configured, skipping")
+ continue
+
+ 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)
+ registry.register(wrapper)
+ logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
+
+ logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
+ except Exception as e:
+ logger.error(f"MCP server '{name}': failed to connect: {e}")
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 72d3afd..4a8cdd9 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -137,8 +137,15 @@ class DingTalkChannel(BaseChannel):
logger.info("DingTalk bot started with Stream Mode")
- # client.start() is an async infinite loop handling the websocket connection
- await self._client.start()
+ # Reconnect loop: restart stream if SDK exits or crashes
+ while self._running:
+ try:
+ await self._client.start()
+ except Exception as e:
+ logger.warning(f"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(f"Failed to start DingTalk channel: {e}")
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 1c176a2..23d1415 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -98,12 +98,15 @@ class FeishuChannel(BaseChannel):
log_level=lark.LogLevel.INFO
)
- # Start WebSocket client in a separate thread
+ # Start WebSocket client in a separate thread with reconnect loop
def run_ws():
- try:
- self._ws_client.start()
- except Exception as e:
- logger.error(f"Feishu WebSocket error: {e}")
+ while self._running:
+ try:
+ self._ws_client.start()
+ except Exception as e:
+ logger.warning(f"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()
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index 5964d30..0e8fe66 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -75,12 +75,15 @@ class QQChannel(BaseChannel):
logger.info("QQ bot started (C2C private message)")
async def _run_bot(self) -> None:
- """Run the bot connection."""
- try:
- await self._client.start(appid=self.config.app_id, secret=self.config.secret)
- except Exception as e:
- logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}")
- self._running = False
+ """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(f"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."""
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index ff46c86..1abd600 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
from loguru import logger
from telegram import BotCommand, Update
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
@@ -121,11 +122,13 @@ class TelegramChannel(BaseChannel):
self._running = True
- # Build the application
- builder = Application.builder().token(self.config.token)
+ # 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))
@@ -386,6 +389,10 @@ class TelegramChannel(BaseChannel):
except Exception as e:
logger.debug(f"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(f"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:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index aa99d55..45d5d3f 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -329,6 +329,7 @@ def gateway(
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
+ mcp_servers=config.tools.mcp_servers,
)
# Set cron callback (needs agent)
@@ -431,6 +432,7 @@ def agent(
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
+ mcp_servers=config.tools.mcp_servers,
)
# Show spinner when logs are off (no output to miss); skip when logs are on
@@ -447,6 +449,7 @@ def agent(
with _thinking_ctx():
response = await agent_loop.process_direct(message, session_id)
_print_agent_response(response, render_markdown=markdown)
+ await agent_loop._close_mcp()
asyncio.run(run_once())
else:
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index f6c861d..2a206e1 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -1,7 +1,7 @@
"""Configuration schema using Pydantic."""
from pathlib import Path
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, ConfigDict
from pydantic_settings import BaseSettings
@@ -213,11 +213,20 @@ class ExecToolConfig(BaseModel):
timeout: int = 60
+class MCPServerConfig(BaseModel):
+ """MCP server connection configuration (stdio or HTTP)."""
+ 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
+
+
class ToolsConfig(BaseModel):
"""Tools configuration."""
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)
class Config(BaseSettings):
@@ -281,6 +290,7 @@ class Config(BaseSettings):
return spec.default_api_base
return None
- class Config:
- env_prefix = "NANOBOT_"
- env_nested_delimiter = "__"
+ model_config = ConfigDict(
+ env_prefix="NANOBOT_",
+ env_nested_delimiter="__"
+ )
diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md
index c8beecb..7db25d8 100644
--- a/nanobot/skills/cron/SKILL.md
+++ b/nanobot/skills/cron/SKILL.md
@@ -7,10 +7,11 @@ description: Schedule reminders and recurring tasks.
Use the `cron` tool to schedule reminders or recurring tasks.
-## Two Modes
+## Three Modes
1. **Reminder** - message is sent directly to user
2. **Task** - message is a task description, agent executes and sends result
+3. **One-time** - runs once at a specific time, then auto-deletes
## Examples
@@ -24,6 +25,11 @@ Dynamic task (agent executes each time):
cron(action="add", message="Check HKUDS/nanobot GitHub stars and report", every_seconds=600)
```
+One-time scheduled task (compute ISO datetime from current time):
+```
+cron(action="add", message="Remind me about the meeting", at="")
+```
+
List/remove:
```
cron(action="list")
@@ -38,3 +44,4 @@ cron(action="remove", job_id="abc123")
| every hour | every_seconds: 3600 |
| every day at 8am | cron_expr: "0 8 * * *" |
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
+| at a specific time | at: ISO datetime string (compute from current time) |
diff --git a/pyproject.toml b/pyproject.toml
index b1b3c81..bdccbf0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,6 +38,7 @@ dependencies = [
"qq-botpy>=1.0.0",
"python-socks[asyncio]>=2.4.0",
"prompt-toolkit>=3.0.0",
+ "mcp>=1.0.0",
]
[project.optional-dependencies]
From e89afe61f1ab87018d488e4677d7d0de0d10bcb1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?=
Date: Thu, 12 Feb 2026 10:01:30 +0100
Subject: [PATCH 077/506] feat(tools): add mcp support
---
README.md | 2 +-
nanobot/agent/context.py | 6 ++-
nanobot/agent/loop.py | 30 ++++++++++++++
nanobot/agent/subagent.py | 15 +++++--
nanobot/agent/tools/mcp.py | 82 ++++++++++++++++++++++++++++++++++++++
nanobot/cli/commands.py | 3 ++
nanobot/config/schema.py | 18 +++++++--
pyproject.toml | 1 +
8 files changed, 146 insertions(+), 11 deletions(-)
create mode 100644 nanobot/agent/tools/mcp.py
diff --git a/README.md b/README.md
index fed25c8..ea606de 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index d807854..b9c0790 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -73,7 +73,9 @@ Skills with available="false" need dependencies installed first - you can try in
def _get_identity(self) -> str:
"""Get the core identity section."""
from datetime import datetime
+ import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
+ tz = _time.strftime("%Z") or "UTC"
workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
@@ -88,7 +90,7 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
- Spawn subagents for complex background tasks
## Current Time
-{now}
+{now} ({tz})
## Runtime
{runtime}
@@ -103,7 +105,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
-Always be helpful, accurate, and concise. When using tools, explain what you're doing.
+Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
When remembering something, write to {workspace_path}/memory/MEMORY.md"""
def _load_bootstrap_files(self) -> str:
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index b764c3d..a3ab678 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -1,6 +1,7 @@
"""Agent loop: the core processing engine."""
import asyncio
+from contextlib import AsyncExitStack
import json
from pathlib import Path
from typing import Any
@@ -46,6 +47,7 @@ class AgentLoop:
cron_service: "CronService | None" = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
+ mcp_servers: dict | None = None,
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
@@ -73,6 +75,9 @@ class AgentLoop:
)
self._running = False
+ self._mcp_servers = mcp_servers or {}
+ self._mcp_stack: AsyncExitStack | None = None
+ self._mcp_connected = False
self._register_default_tools()
def _register_default_tools(self) -> None:
@@ -107,9 +112,20 @@ class AgentLoop:
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
+ async def _connect_mcp(self) -> None:
+ """Connect to configured MCP servers (one-time, lazy)."""
+ if self._mcp_connected or not self._mcp_servers:
+ return
+ self._mcp_connected = True
+ from nanobot.agent.tools.mcp import connect_mcp_servers
+ self._mcp_stack = AsyncExitStack()
+ await self._mcp_stack.__aenter__()
+ await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
+
async def run(self) -> None:
"""Run the agent loop, processing messages from the bus."""
self._running = True
+ await self._connect_mcp()
logger.info("Agent loop started")
while self._running:
@@ -136,6 +152,15 @@ class AgentLoop:
except asyncio.TimeoutError:
continue
+ async def _close_mcp(self) -> None:
+ """Close MCP connections."""
+ if self._mcp_stack:
+ try:
+ await self._mcp_stack.aclose()
+ except (RuntimeError, BaseExceptionGroup):
+ pass # MCP SDK cancel scope cleanup is noisy but harmless
+ self._mcp_stack = None
+
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
@@ -225,6 +250,8 @@ class AgentLoop:
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
+ # Interleaved CoT: reflect before next action
+ messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
# No tool calls, we're done
final_content = response.content
@@ -330,6 +357,8 @@ class AgentLoop:
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
+ # Interleaved CoT: reflect before next action
+ messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
final_content = response.content
break
@@ -367,6 +396,7 @@ class AgentLoop:
Returns:
The agent's response.
"""
+ await self._connect_mcp()
msg = InboundMessage(
channel=channel,
sender_id="user",
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 6113efb..9e0cd7c 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -12,7 +12,7 @@ from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.agent.tools.registry import ToolRegistry
-from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, ListDirTool
+from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
@@ -101,6 +101,7 @@ class SubagentManager:
allowed_dir = self.workspace if self.restrict_to_workspace else None
tools.register(ReadFileTool(allowed_dir=allowed_dir))
tools.register(WriteFileTool(allowed_dir=allowed_dir))
+ tools.register(EditFileTool(allowed_dir=allowed_dir))
tools.register(ListDirTool(allowed_dir=allowed_dir))
tools.register(ExecTool(
working_dir=str(self.workspace),
@@ -210,12 +211,17 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
def _build_subagent_prompt(self, task: str) -> str:
"""Build a focused system prompt for the subagent."""
+ from datetime import datetime
+ import time as _time
+ now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
+ tz = _time.strftime("%Z") or "UTC"
+
return f"""# Subagent
-You are a subagent spawned by the main agent to complete a specific task.
+## Current Time
+{now} ({tz})
-## Your Task
-{task}
+You are a subagent spawned by the main agent to complete a specific task.
## Rules
1. Stay focused - complete only the assigned task, nothing else
@@ -236,6 +242,7 @@ You are a subagent spawned by the main agent to complete a specific task.
## Workspace
Your workspace is at: {self.workspace}
+Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
When you have completed the task, provide a clear summary of your findings or actions."""
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
new file mode 100644
index 0000000..bcef4aa
--- /dev/null
+++ b/nanobot/agent/tools/mcp.py
@@ -0,0 +1,82 @@
+"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
+
+from contextlib import AsyncExitStack
+from typing import Any
+
+from loguru import logger
+
+from nanobot.agent.tools.base import Tool
+from nanobot.agent.tools.registry import ToolRegistry
+
+
+class MCPToolWrapper(Tool):
+ """Wraps a single MCP server tool as a nanobot Tool."""
+
+ def __init__(self, session, server_name: str, tool_def):
+ self._session = session
+ self._server = server_name
+ 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": {}}
+
+ @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
+ result = await self._session.call_tool(
+ self._name.removeprefix(f"mcp_{self._server}_"), arguments=kwargs
+ )
+ parts = []
+ for block in result.content:
+ if isinstance(block, types.TextContent):
+ parts.append(block.text)
+ else:
+ parts.append(str(block))
+ return "\n".join(parts) or "(no output)"
+
+
+async def connect_mcp_servers(
+ mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
+) -> None:
+ """Connect to configured MCP servers and register their tools."""
+ from mcp import ClientSession, StdioServerParameters
+ from mcp.client.stdio import stdio_client
+
+ for name, cfg in mcp_servers.items():
+ try:
+ if cfg.command:
+ params = StdioServerParameters(
+ command=cfg.command, args=cfg.args, env=cfg.env or None
+ )
+ read, write = await stack.enter_async_context(stdio_client(params))
+ elif cfg.url:
+ from mcp.client.streamable_http import streamable_http_client
+ read, write, _ = await stack.enter_async_context(
+ streamable_http_client(cfg.url)
+ )
+ else:
+ logger.warning(f"MCP server '{name}': no command or url configured, skipping")
+ continue
+
+ 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)
+ registry.register(wrapper)
+ logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
+
+ logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
+ except Exception as e:
+ logger.error(f"MCP server '{name}': failed to connect: {e}")
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index aa99d55..45d5d3f 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -329,6 +329,7 @@ def gateway(
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
+ mcp_servers=config.tools.mcp_servers,
)
# Set cron callback (needs agent)
@@ -431,6 +432,7 @@ def agent(
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
+ mcp_servers=config.tools.mcp_servers,
)
# Show spinner when logs are off (no output to miss); skip when logs are on
@@ -447,6 +449,7 @@ def agent(
with _thinking_ctx():
response = await agent_loop.process_direct(message, session_id)
_print_agent_response(response, render_markdown=markdown)
+ await agent_loop._close_mcp()
asyncio.run(run_once())
else:
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index f6c861d..2a206e1 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -1,7 +1,7 @@
"""Configuration schema using Pydantic."""
from pathlib import Path
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, ConfigDict
from pydantic_settings import BaseSettings
@@ -213,11 +213,20 @@ class ExecToolConfig(BaseModel):
timeout: int = 60
+class MCPServerConfig(BaseModel):
+ """MCP server connection configuration (stdio or HTTP)."""
+ 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
+
+
class ToolsConfig(BaseModel):
"""Tools configuration."""
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)
class Config(BaseSettings):
@@ -281,6 +290,7 @@ class Config(BaseSettings):
return spec.default_api_base
return None
- class Config:
- env_prefix = "NANOBOT_"
- env_nested_delimiter = "__"
+ model_config = ConfigDict(
+ env_prefix="NANOBOT_",
+ env_nested_delimiter="__"
+ )
diff --git a/pyproject.toml b/pyproject.toml
index b1b3c81..bdccbf0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,6 +38,7 @@ dependencies = [
"qq-botpy>=1.0.0",
"python-socks[asyncio]>=2.4.0",
"prompt-toolkit>=3.0.0",
+ "mcp>=1.0.0",
]
[project.optional-dependencies]
From 61e9f7f58ad3ce21df408615adadeac43db0205d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?=
Date: Thu, 12 Feb 2026 10:16:52 +0100
Subject: [PATCH 078/506] chore: revert unrelated changes, keep only MCP
support
---
README.md | 2 +-
nanobot/agent/context.py | 6 ++----
nanobot/agent/loop.py | 4 ----
nanobot/agent/subagent.py | 15 ++++-----------
nanobot/agent/tools/cron.py | 19 +++----------------
nanobot/channels/dingtalk.py | 11 ++---------
nanobot/channels/feishu.py | 13 +++++--------
nanobot/channels/qq.py | 15 ++++++---------
nanobot/channels/telegram.py | 11 ++---------
nanobot/skills/cron/SKILL.md | 9 +--------
10 files changed, 26 insertions(+), 79 deletions(-)
diff --git a/README.md b/README.md
index ea606de..fed25c8 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index b9c0790..d807854 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -73,9 +73,7 @@ Skills with available="false" need dependencies installed first - you can try in
def _get_identity(self) -> str:
"""Get the core identity section."""
from datetime import datetime
- import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
- tz = _time.strftime("%Z") or "UTC"
workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
@@ -90,7 +88,7 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
- Spawn subagents for complex background tasks
## Current Time
-{now} ({tz})
+{now}
## Runtime
{runtime}
@@ -105,7 +103,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
-Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
+Always be helpful, accurate, and concise. When using tools, explain what you're doing.
When remembering something, write to {workspace_path}/memory/MEMORY.md"""
def _load_bootstrap_files(self) -> str:
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index a3ab678..b15803a 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -250,8 +250,6 @@ class AgentLoop:
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
- # Interleaved CoT: reflect before next action
- messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
# No tool calls, we're done
final_content = response.content
@@ -357,8 +355,6 @@ class AgentLoop:
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
- # Interleaved CoT: reflect before next action
- messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
final_content = response.content
break
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 9e0cd7c..6113efb 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -12,7 +12,7 @@ from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.agent.tools.registry import ToolRegistry
-from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
+from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
@@ -101,7 +101,6 @@ class SubagentManager:
allowed_dir = self.workspace if self.restrict_to_workspace else None
tools.register(ReadFileTool(allowed_dir=allowed_dir))
tools.register(WriteFileTool(allowed_dir=allowed_dir))
- tools.register(EditFileTool(allowed_dir=allowed_dir))
tools.register(ListDirTool(allowed_dir=allowed_dir))
tools.register(ExecTool(
working_dir=str(self.workspace),
@@ -211,18 +210,13 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
def _build_subagent_prompt(self, task: str) -> str:
"""Build a focused system prompt for the subagent."""
- from datetime import datetime
- import time as _time
- now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
- tz = _time.strftime("%Z") or "UTC"
-
return f"""# Subagent
-## Current Time
-{now} ({tz})
-
You are a subagent spawned by the main agent to complete a specific task.
+## Your Task
+{task}
+
## Rules
1. Stay focused - complete only the assigned task, nothing else
2. Your final response will be reported back to the main agent
@@ -242,7 +236,6 @@ You are a subagent spawned by the main agent to complete a specific task.
## Workspace
Your workspace is at: {self.workspace}
-Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed)
When you have completed the task, provide a clear summary of your findings or actions."""
diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py
index 9f1ecdb..ec0d2cd 100644
--- a/nanobot/agent/tools/cron.py
+++ b/nanobot/agent/tools/cron.py
@@ -50,10 +50,6 @@ class CronTool(Tool):
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
},
- "at": {
- "type": "string",
- "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
- },
"job_id": {
"type": "string",
"description": "Job ID (for remove)"
@@ -68,38 +64,30 @@ class CronTool(Tool):
message: str = "",
every_seconds: int | None = None,
cron_expr: str | None = None,
- at: str | None = None,
job_id: str | None = None,
**kwargs: Any
) -> str:
if action == "add":
- return self._add_job(message, every_seconds, cron_expr, at)
+ return self._add_job(message, every_seconds, cron_expr)
elif action == "list":
return self._list_jobs()
elif action == "remove":
return self._remove_job(job_id)
return f"Unknown action: {action}"
- def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str:
+ def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None) -> str:
if not message:
return "Error: message is required for add"
if not self._channel or not self._chat_id:
return "Error: no session context (channel/chat_id)"
# Build schedule
- 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)
- elif at:
- from datetime import datetime
- 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"
+ return "Error: either every_seconds or cron_expr is required"
job = self._cron.add_job(
name=message[:30],
@@ -108,7 +96,6 @@ class CronTool(Tool):
deliver=True,
channel=self._channel,
to=self._chat_id,
- delete_after_run=delete_after,
)
return f"Created job '{job.name}' (id: {job.id})"
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 4a8cdd9..72d3afd 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -137,15 +137,8 @@ class DingTalkChannel(BaseChannel):
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(f"DingTalk stream error: {e}")
- if self._running:
- logger.info("Reconnecting DingTalk stream in 5 seconds...")
- await asyncio.sleep(5)
+ # client.start() is an async infinite loop handling the websocket connection
+ await self._client.start()
except Exception as e:
logger.exception(f"Failed to start DingTalk channel: {e}")
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 23d1415..1c176a2 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -98,15 +98,12 @@ class FeishuChannel(BaseChannel):
log_level=lark.LogLevel.INFO
)
- # Start WebSocket client in a separate thread with reconnect loop
+ # Start WebSocket client in a separate thread
def run_ws():
- while self._running:
- try:
- self._ws_client.start()
- except Exception as e:
- logger.warning(f"Feishu WebSocket error: {e}")
- if self._running:
- import time; time.sleep(5)
+ try:
+ self._ws_client.start()
+ except Exception as e:
+ logger.error(f"Feishu WebSocket error: {e}")
self._ws_thread = threading.Thread(target=run_ws, daemon=True)
self._ws_thread.start()
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index 0e8fe66..5964d30 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -75,15 +75,12 @@ class QQChannel(BaseChannel):
logger.info("QQ bot started (C2C private message)")
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(f"QQ bot error: {e}")
- if self._running:
- logger.info("Reconnecting QQ bot in 5 seconds...")
- await asyncio.sleep(5)
+ """Run the bot connection."""
+ try:
+ await self._client.start(appid=self.config.app_id, secret=self.config.secret)
+ except Exception as e:
+ logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}")
+ self._running = False
async def stop(self) -> None:
"""Stop the QQ bot."""
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 1abd600..ff46c86 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -9,7 +9,6 @@ from typing import TYPE_CHECKING
from loguru import logger
from telegram import BotCommand, Update
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
@@ -122,13 +121,11 @@ class TelegramChannel(BaseChannel):
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)
+ # Build the application
+ builder = Application.builder().token(self.config.token)
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))
@@ -389,10 +386,6 @@ class TelegramChannel(BaseChannel):
except Exception as e:
logger.debug(f"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(f"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:
diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md
index 7db25d8..c8beecb 100644
--- a/nanobot/skills/cron/SKILL.md
+++ b/nanobot/skills/cron/SKILL.md
@@ -7,11 +7,10 @@ description: Schedule reminders and recurring tasks.
Use the `cron` tool to schedule reminders or recurring tasks.
-## Three Modes
+## Two Modes
1. **Reminder** - message is sent directly to user
2. **Task** - message is a task description, agent executes and sends result
-3. **One-time** - runs once at a specific time, then auto-deletes
## Examples
@@ -25,11 +24,6 @@ Dynamic task (agent executes each time):
cron(action="add", message="Check HKUDS/nanobot GitHub stars and report", every_seconds=600)
```
-One-time scheduled task (compute ISO datetime from current time):
-```
-cron(action="add", message="Remind me about the meeting", at="")
-```
-
List/remove:
```
cron(action="list")
@@ -44,4 +38,3 @@ cron(action="remove", job_id="abc123")
| every hour | every_seconds: 3600 |
| every day at 8am | cron_expr: "0 8 * * *" |
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
-| at a specific time | at: ISO datetime string (compute from current time) |
From d30523f460b26132091c96ea9ef73003a53e2e9b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?=
Date: Thu, 12 Feb 2026 10:44:25 +0100
Subject: [PATCH 079/506] fix(mcp): clean up connections on exit in interactive
and gateway modes
---
nanobot/cli/commands.py | 45 +++++++++++++++++++++++------------------
1 file changed, 25 insertions(+), 20 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 45d5d3f..cab4d41 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -387,6 +387,8 @@ def gateway(
)
except KeyboardInterrupt:
console.print("\nShutting down...")
+ finally:
+ await agent._close_mcp()
heartbeat.stop()
cron.stop()
agent.stop()
@@ -465,30 +467,33 @@ def agent(
signal.signal(signal.SIGINT, _exit_on_sigint)
async def run_interactive():
- while True:
- try:
- _flush_pending_tty_input()
- user_input = await _read_interactive_input_async()
- command = user_input.strip()
- if not command:
- continue
+ try:
+ while True:
+ try:
+ _flush_pending_tty_input()
+ user_input = await _read_interactive_input_async()
+ command = user_input.strip()
+ if not command:
+ continue
- if _is_exit_command(command):
+ if _is_exit_command(command):
+ _restore_terminal()
+ console.print("\nGoodbye!")
+ break
+
+ with _thinking_ctx():
+ response = await agent_loop.process_direct(user_input, session_id)
+ _print_agent_response(response, render_markdown=markdown)
+ except KeyboardInterrupt:
_restore_terminal()
console.print("\nGoodbye!")
break
-
- with _thinking_ctx():
- response = await agent_loop.process_direct(user_input, session_id)
- _print_agent_response(response, render_markdown=markdown)
- except KeyboardInterrupt:
- _restore_terminal()
- console.print("\nGoodbye!")
- break
- except EOFError:
- _restore_terminal()
- console.print("\nGoodbye!")
- break
+ except EOFError:
+ _restore_terminal()
+ console.print("\nGoodbye!")
+ break
+ finally:
+ await agent_loop._close_mcp()
asyncio.run(run_interactive())
From a3599b97b9bdac660b0cc4ac89c77d790b7cac92 Mon Sep 17 00:00:00 2001
From: lemon
Date: Thu, 12 Feb 2026 19:12:38 +0800
Subject: [PATCH 080/506] fix: bug #370, support temperature configuration
---
nanobot/agent/loop.py | 8 ++++++--
nanobot/cli/commands.py | 2 ++
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 46a31bd..b43e27d 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -41,6 +41,7 @@ class AgentLoop:
workspace: Path,
model: str | None = None,
max_iterations: int = 20,
+ temperature: float = 0.7,
brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None,
cron_service: "CronService | None" = None,
@@ -54,6 +55,7 @@ class AgentLoop:
self.workspace = workspace
self.model = model or provider.get_default_model()
self.max_iterations = max_iterations
+ self.temperature = temperature
self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig()
self.cron_service = cron_service
@@ -195,7 +197,8 @@ class AgentLoop:
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
- model=self.model
+ model=self.model,
+ temperature=self.temperature
)
# Handle tool calls
@@ -305,7 +308,8 @@ class AgentLoop:
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
- model=self.model
+ model=self.model,
+ temperature=self.temperature
)
if response.has_tool_calls:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index aa99d55..812fbf0 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -323,6 +323,7 @@ def gateway(
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
+ temperature=config.agents.defaults.temperature,
max_iterations=config.agents.defaults.max_tool_iterations,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
@@ -428,6 +429,7 @@ def agent(
bus=bus,
provider=provider,
workspace=config.workspace_path,
+ temperature=config.agents.defaults.temperature,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
restrict_to_workspace=config.tools.restrict_to_workspace,
From 94c21fc23579eec6fc0b473a09e356df99f9fffd Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Feb 2026 15:02:52 +0000
Subject: [PATCH 081/506] =?UTF-8?q?feat:=20redesign=20memory=20system=20?=
=?UTF-8?q?=E2=80=94=20two-layer=20architecture=20with=20grep-based=20retr?=
=?UTF-8?q?ieval?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 2 +-
nanobot/agent/context.py | 7 ++-
nanobot/agent/loop.py | 88 +++++++++++++++++++++++++---
nanobot/agent/memory.py | 103 ++++-----------------------------
nanobot/cli/commands.py | 11 +++-
nanobot/config/schema.py | 1 +
nanobot/skills/memory/SKILL.md | 31 ++++++++++
nanobot/utils/helpers.py | 11 ----
workspace/AGENTS.md | 4 +-
9 files changed, 141 insertions(+), 117 deletions(-)
create mode 100644 nanobot/skills/memory/SKILL.md
diff --git a/README.md b/README.md
index ea606de..f36f9dc 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,562 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index b9c0790..f460f2b 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -97,8 +97,8 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
## Workspace
Your workspace is at: {workspace_path}
-- Memory files: {workspace_path}/memory/MEMORY.md
-- Daily notes: {workspace_path}/memory/YYYY-MM-DD.md
+- 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
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
@@ -106,7 +106,8 @@ Only use the 'message' tool when you need to send a message to a specific chat c
For normal conversation, just respond with text - do not call the message tool.
Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
-When remembering something, write to {workspace_path}/memory/MEMORY.md"""
+When remembering something important, write to {workspace_path}/memory/MEMORY.md
+To recall past events, grep {workspace_path}/memory/HISTORY.md"""
def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace."""
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 46a31bd..a660436 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -18,6 +18,7 @@ from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.cron import CronTool
+from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager
from nanobot.session.manager import SessionManager
@@ -41,6 +42,7 @@ class AgentLoop:
workspace: Path,
model: str | None = None,
max_iterations: int = 20,
+ memory_window: int = 50,
brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None,
cron_service: "CronService | None" = None,
@@ -54,6 +56,7 @@ class AgentLoop:
self.workspace = workspace
self.model = model or provider.get_default_model()
self.max_iterations = max_iterations
+ self.memory_window = memory_window
self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig()
self.cron_service = cron_service
@@ -141,12 +144,13 @@ class AgentLoop:
self._running = False
logger.info("Agent loop stopping")
- async def _process_message(self, msg: InboundMessage) -> OutboundMessage | None:
+ async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None:
"""
Process a single inbound message.
Args:
msg: The inbound message to process.
+ session_key: Override session key (used by process_direct).
Returns:
The response message, or None if no response needed.
@@ -160,7 +164,11 @@ class AgentLoop:
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
# Get or create session
- session = self.sessions.get_or_create(msg.session_key)
+ session = self.sessions.get_or_create(session_key or msg.session_key)
+
+ # Consolidate memory before processing if session is too large
+ if len(session.messages) > self.memory_window:
+ await self._consolidate_memory(session)
# Update tool contexts
message_tool = self.tools.get("message")
@@ -187,6 +195,7 @@ class AgentLoop:
# Agent loop
iteration = 0
final_content = None
+ tools_used: list[str] = []
while iteration < self.max_iterations:
iteration += 1
@@ -219,6 +228,7 @@ class AgentLoop:
# Execute tools
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(f"Tool call: {tool_call.name}({args_str[:200]})")
result = await self.tools.execute(tool_call.name, tool_call.arguments)
@@ -239,9 +249,10 @@ class AgentLoop:
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
- # Save to session
+ # Save to session (include tool names so consolidation sees what happened)
session.add_message("user", msg.content)
- session.add_message("assistant", final_content)
+ session.add_message("assistant", final_content,
+ tools_used=tools_used if tools_used else None)
self.sessions.save(session)
return OutboundMessage(
@@ -352,6 +363,67 @@ class AgentLoop:
content=final_content
)
+ async def _consolidate_memory(self, session) -> None:
+ """Consolidate old messages into MEMORY.md + HISTORY.md, then trim session."""
+ memory = MemoryStore(self.workspace)
+ keep_count = min(10, max(2, self.memory_window // 2))
+ old_messages = session.messages[:-keep_count] # Everything except recent ones
+ if not old_messages:
+ return
+ logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
+
+ # Format messages for LLM (include tool names when available)
+ 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']}")
+ conversation = "\n".join(lines)
+ current_memory = memory.read_long_term()
+
+ prompt = f"""You are a memory consolidation agent. Process this conversation and return a JSON object with exactly two keys:
+
+1. "history_entry": A paragraph (2-5 sentences) summarizing the key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.
+
+2. "memory_update": The updated long-term memory content. Add any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.
+
+## Current Long-term Memory
+{current_memory or "(empty)"}
+
+## Conversation to Process
+{conversation}
+
+Respond with ONLY valid JSON, no markdown fences."""
+
+ try:
+ response = await self.provider.chat(
+ messages=[
+ {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
+ {"role": "user", "content": prompt},
+ ],
+ model=self.model,
+ )
+ import json as _json
+ text = (response.content or "").strip()
+ # Strip markdown fences that LLMs often add despite instructions
+ if text.startswith("```"):
+ text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
+ result = _json.loads(text)
+
+ if entry := result.get("history_entry"):
+ memory.append_history(entry)
+ if update := result.get("memory_update"):
+ if update != current_memory:
+ memory.write_long_term(update)
+
+ # Trim session to recent messages
+ session.messages = session.messages[-keep_count:]
+ self.sessions.save(session)
+ logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages")
+ except Exception as e:
+ logger.error(f"Memory consolidation failed: {e}")
+
async def process_direct(
self,
content: str,
@@ -364,9 +436,9 @@ class AgentLoop:
Args:
content: The message content.
- session_key: Session identifier.
- channel: Source channel (for context).
- chat_id: Source chat ID (for context).
+ session_key: Session identifier (overrides channel:chat_id for session lookup).
+ channel: Source channel (for tool context routing).
+ chat_id: Source chat ID (for tool context routing).
Returns:
The agent's response.
@@ -378,5 +450,5 @@ class AgentLoop:
content=content
)
- response = await self._process_message(msg)
+ response = await self._process_message(msg, session_key=session_key)
return response.content if response else ""
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index 453407e..29477c4 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -1,109 +1,30 @@
"""Memory system for persistent agent memory."""
from pathlib import Path
-from datetime import datetime
-from nanobot.utils.helpers import ensure_dir, today_date
+from nanobot.utils.helpers import ensure_dir
class MemoryStore:
- """
- Memory system for the agent.
-
- Supports daily notes (memory/YYYY-MM-DD.md) and long-term memory (MEMORY.md).
- """
-
+ """Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
+
def __init__(self, workspace: Path):
- self.workspace = workspace
self.memory_dir = ensure_dir(workspace / "memory")
self.memory_file = self.memory_dir / "MEMORY.md"
-
- def get_today_file(self) -> Path:
- """Get path to today's memory file."""
- return self.memory_dir / f"{today_date()}.md"
-
- def read_today(self) -> str:
- """Read today's memory notes."""
- today_file = self.get_today_file()
- if today_file.exists():
- return today_file.read_text(encoding="utf-8")
- return ""
-
- def append_today(self, content: str) -> None:
- """Append content to today's memory notes."""
- today_file = self.get_today_file()
-
- if today_file.exists():
- existing = today_file.read_text(encoding="utf-8")
- content = existing + "\n" + content
- else:
- # Add header for new day
- header = f"# {today_date()}\n\n"
- content = header + content
-
- today_file.write_text(content, encoding="utf-8")
-
+ self.history_file = self.memory_dir / "HISTORY.md"
+
def read_long_term(self) -> str:
- """Read long-term memory (MEMORY.md)."""
if self.memory_file.exists():
return self.memory_file.read_text(encoding="utf-8")
return ""
-
+
def write_long_term(self, content: str) -> None:
- """Write to long-term memory (MEMORY.md)."""
self.memory_file.write_text(content, encoding="utf-8")
-
- def get_recent_memories(self, days: int = 7) -> str:
- """
- Get memories from the last N days.
-
- Args:
- days: Number of days to look back.
-
- Returns:
- Combined memory content.
- """
- from datetime import timedelta
-
- memories = []
- today = datetime.now().date()
-
- for i in range(days):
- date = today - timedelta(days=i)
- date_str = date.strftime("%Y-%m-%d")
- file_path = self.memory_dir / f"{date_str}.md"
-
- if file_path.exists():
- content = file_path.read_text(encoding="utf-8")
- memories.append(content)
-
- return "\n\n---\n\n".join(memories)
-
- def list_memory_files(self) -> list[Path]:
- """List all memory files sorted by date (newest first)."""
- if not self.memory_dir.exists():
- return []
-
- files = list(self.memory_dir.glob("????-??-??.md"))
- return sorted(files, reverse=True)
-
+
+ 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:
- """
- Get memory context for the agent.
-
- Returns:
- Formatted memory context including long-term and recent memories.
- """
- parts = []
-
- # Long-term memory
long_term = self.read_long_term()
- if long_term:
- parts.append("## Long-term Memory\n" + long_term)
-
- # Today's notes
- today = self.read_today()
- if today:
- parts.append("## Today's Notes\n" + today)
-
- return "\n\n".join(parts) if parts else ""
+ return f"## Long-term Memory\n{long_term}" if long_term else ""
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index aa99d55..2aa5688 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -200,7 +200,7 @@ You are a helpful AI assistant. Be concise, accurate, and friendly.
- Always explain what you're doing before taking actions
- Ask for clarification when the request is ambiguous
- Use tools to help accomplish tasks
-- Remember important information in your memory files
+- Remember important information in memory/MEMORY.md; past events are logged in memory/HISTORY.md
""",
"SOUL.md": """# Soul
@@ -258,6 +258,11 @@ This file stores important information that should persist across sessions.
(Things to remember)
""")
console.print(" [dim]Created memory/MEMORY.md[/dim]")
+
+ history_file = memory_dir / "HISTORY.md"
+ if not history_file.exists():
+ history_file.write_text("")
+ console.print(" [dim]Created memory/HISTORY.md[/dim]")
# Create skills directory for custom user skills
skills_dir = workspace / "skills"
@@ -324,6 +329,7 @@ def gateway(
workspace=config.workspace_path,
model=config.agents.defaults.model,
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,
cron_service=cron,
@@ -428,6 +434,9 @@ def agent(
bus=bus,
provider=provider,
workspace=config.workspace_path,
+ model=config.agents.defaults.model,
+ 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,
restrict_to_workspace=config.tools.restrict_to_workspace,
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 19feba4..fdf1868 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -161,6 +161,7 @@ class AgentDefaults(BaseModel):
max_tokens: int = 8192
temperature: float = 0.7
max_tool_iterations: int = 20
+ memory_window: int = 50
class AgentsConfig(BaseModel):
diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md
new file mode 100644
index 0000000..39adbde
--- /dev/null
+++ b/nanobot/skills/memory/SKILL.md
@@ -0,0 +1,31 @@
+---
+name: memory
+description: Two-layer memory system with grep-based recall.
+always: true
+---
+
+# Memory
+
+## Structure
+
+- `memory/MEMORY.md` — Long-term facts (preferences, project context, relationships). Always loaded into your context.
+- `memory/HISTORY.md` — Append-only event log. NOT loaded into context. Search it with grep.
+
+## Search Past Events
+
+```bash
+grep -i "keyword" memory/HISTORY.md
+```
+
+Use the `exec` tool to run grep. Combine patterns: `grep -iE "meeting|deadline" memory/HISTORY.md`
+
+## When to Update MEMORY.md
+
+Write important facts immediately using `edit_file` or `write_file`:
+- User preferences ("I prefer dark mode")
+- Project context ("The API uses OAuth2")
+- Relationships ("Alice is the project lead")
+
+## Auto-consolidation
+
+Old conversations are automatically summarized and appended to HISTORY.md when the session grows large. Long-term facts are extracted to MEMORY.md. You don't need to manage this.
diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py
index 667b4c4..62f80ac 100644
--- a/nanobot/utils/helpers.py
+++ b/nanobot/utils/helpers.py
@@ -37,23 +37,12 @@ def get_sessions_path() -> Path:
return ensure_dir(get_data_path() / "sessions")
-def get_memory_path(workspace: Path | None = None) -> Path:
- """Get the memory directory within the workspace."""
- ws = workspace or get_workspace_path()
- return ensure_dir(ws / "memory")
-
-
def get_skills_path(workspace: Path | None = None) -> Path:
"""Get the skills directory within the workspace."""
ws = workspace or get_workspace_path()
return ensure_dir(ws / "skills")
-def today_date() -> str:
- """Get today's date in YYYY-MM-DD format."""
- return datetime.now().strftime("%Y-%m-%d")
-
-
def timestamp() -> str:
"""Get current timestamp in ISO format."""
return datetime.now().isoformat()
diff --git a/workspace/AGENTS.md b/workspace/AGENTS.md
index b4e5b5f..69bd823 100644
--- a/workspace/AGENTS.md
+++ b/workspace/AGENTS.md
@@ -20,8 +20,8 @@ You have access to:
## Memory
-- Use `memory/` directory for daily notes
-- Use `MEMORY.md` for long-term information
+- `memory/MEMORY.md` — long-term facts (preferences, context, relationships)
+- `memory/HISTORY.md` — append-only event log, search with grep to recall past events
## Scheduled Reminders
From 890d7cf85327c13500be5ed13db70f87a4e91243 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 12 Feb 2026 15:28:07 +0000
Subject: [PATCH 082/506] docs: news about the redesigned memory system
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index f36f9dc..ef16273 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
## 📢 News
+- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
From dbbbecb25c0fb528090766c379265dc84faa13c9 Mon Sep 17 00:00:00 2001
From: 3927o <1624497311@qq.com>
Date: Thu, 12 Feb 2026 23:57:34 +0800
Subject: [PATCH 083/506] feat: improve fallback message when max iterations
reached
---
nanobot/agent/loop.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index a660436..4532b4c 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -243,7 +243,10 @@ class AgentLoop:
break
if final_content is None:
- final_content = "I've completed processing but have no response to give."
+ if iteration >= self.max_iterations:
+ final_content = f"Reached {self.max_iterations} iterations without completion."
+ else:
+ final_content = "I've completed processing but have no response to give."
# Log response preview
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
From 24a90af6d32f8fa3e630ea40d0a234b335cf30b9 Mon Sep 17 00:00:00 2001
From: worenidewen
Date: Fri, 13 Feb 2026 01:24:48 +0800
Subject: [PATCH 084/506] feat: add /new command
---
nanobot/cli/commands.py | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2aa5688..eb16782 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -28,6 +28,7 @@ app = typer.Typer(
console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
+NEW_SESSION_COMMANDS = {"/new", "/reset"}
# ---------------------------------------------------------------------------
# CLI input: prompt_toolkit for editing, paste, history, and display
@@ -111,6 +112,11 @@ def _is_exit_command(command: str) -> bool:
return command.lower() in EXIT_COMMANDS
+def _is_new_session_command(command: str) -> bool:
+ """Return True when input should clear the session history."""
+ return command.lower() in NEW_SESSION_COMMANDS
+
+
async def _read_interactive_input_async() -> str:
"""Read user input using prompt_toolkit (handles paste, history, display).
@@ -484,6 +490,15 @@ def agent(
console.print("\nGoodbye!")
break
+ if _is_new_session_command(command):
+ session = agent_loop.sessions.get_or_create(session_id)
+ session.clear()
+ agent_loop.sessions.save(session)
+ console.print(
+ f"\n[green]{__logo__} Started new session. History cleared.[/green]\n"
+ )
+ continue
+
with _thinking_ctx():
response = await agent_loop.process_direct(user_input, session_id)
_print_agent_response(response, render_markdown=markdown)
From f016025f632ee54165a28e52cfae985dc3c16cd1 Mon Sep 17 00:00:00 2001
From: Luke Milby
Date: Thu, 12 Feb 2026 22:20:56 -0500
Subject: [PATCH 085/506] add feature to onboarding that will ask to generate
missing workspace files
---
nanobot/cli/commands.py | 30 +++++++++++++++++++++---------
1 file changed, 21 insertions(+), 9 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2aa5688..3ced25e 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -163,20 +163,32 @@ def onboard():
if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
- if not typer.confirm("Overwrite?"):
- raise typer.Exit()
-
- # Create default config
- config = Config()
- save_config(config)
- console.print(f"[green]✓[/green] Created config at {config_path}")
+ if typer.confirm("Overwrite?"):
+ # Create default config
+ config = Config()
+ save_config(config)
+ console.print(f"[green]✓[/green] Created config at {config_path}")
+ else:
+ # Create default config
+ config = Config()
+ save_config(config)
+ console.print(f"[green]✓[/green] Created config at {config_path}")
# Create workspace
workspace = get_workspace_path()
- console.print(f"[green]✓[/green] Created workspace at {workspace}")
+
+ create_templates = True
+ if workspace.exists():
+ console.print(f"[yellow]Workspace already exists at {workspace}[/yellow]")
+ if not typer.confirm("Create missing default templates? (will not overwrite existing files)"):
+ create_templates = False
+ else:
+ workspace.mkdir(parents=True, exist_ok=True)
+ console.print(f"[green]✓[/green] Created workspace at {workspace}")
# Create default bootstrap files
- _create_workspace_templates(workspace)
+ if create_templates:
+ _create_workspace_templates(workspace)
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
From 903caaa642633a95cd533b1bf50bd56c57fbfe67 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 13 Feb 2026 03:30:21 +0000
Subject: [PATCH 086/506] feat: unified slash commands (/new, /help) across all
channels
---
README.md | 2 +-
nanobot/agent/loop.py | 34 +++++++++++++++++-------
nanobot/channels/manager.py | 9 ++-----
nanobot/channels/telegram.py | 50 +++++++-----------------------------
nanobot/cli/commands.py | 17 +-----------
5 files changed, 38 insertions(+), 74 deletions(-)
diff --git a/README.md b/README.md
index ef16273..f5d3e7c 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,562 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index a660436..80aeac4 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -164,7 +164,20 @@ class AgentLoop:
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
# Get or create session
- session = self.sessions.get_or_create(session_key or msg.session_key)
+ key = session_key or msg.session_key
+ session = self.sessions.get_or_create(key)
+
+ # Handle slash commands
+ cmd = msg.content.strip().lower()
+ if cmd == "/new":
+ await self._consolidate_memory(session, archive_all=True)
+ session.clear()
+ self.sessions.save(session)
+ return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
+ content="🐈 New session started. Memory consolidated.")
+ if cmd == "/help":
+ return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
+ content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
# Consolidate memory before processing if session is too large
if len(session.messages) > self.memory_window:
@@ -363,11 +376,17 @@ class AgentLoop:
content=final_content
)
- async def _consolidate_memory(self, session) -> None:
+ async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
"""Consolidate old messages into MEMORY.md + HISTORY.md, then trim session."""
+ if not session.messages:
+ return
memory = MemoryStore(self.workspace)
- keep_count = min(10, max(2, self.memory_window // 2))
- old_messages = session.messages[:-keep_count] # Everything except recent ones
+ if archive_all:
+ old_messages = session.messages
+ keep_count = 0
+ else:
+ keep_count = min(10, max(2, self.memory_window // 2))
+ old_messages = session.messages[:-keep_count]
if not old_messages:
return
logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
@@ -404,12 +423,10 @@ Respond with ONLY valid JSON, no markdown fences."""
],
model=self.model,
)
- import json as _json
text = (response.content or "").strip()
- # Strip markdown fences that LLMs often add despite instructions
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
- result = _json.loads(text)
+ result = json.loads(text)
if entry := result.get("history_entry"):
memory.append_history(entry)
@@ -417,8 +434,7 @@ Respond with ONLY valid JSON, no markdown fences."""
if update != current_memory:
memory.write_long_term(update)
- # Trim session to recent messages
- session.messages = session.messages[-keep_count:]
+ session.messages = session.messages[-keep_count:] if keep_count else []
self.sessions.save(session)
logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages")
except Exception as e:
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 464fa97..e860d26 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
-from typing import Any, TYPE_CHECKING
+from typing import Any
from loguru import logger
@@ -12,9 +12,6 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
-if TYPE_CHECKING:
- from nanobot.session.manager import SessionManager
-
class ChannelManager:
"""
@@ -26,10 +23,9 @@ class ChannelManager:
- Route outbound messages
"""
- def __init__(self, config: Config, bus: MessageBus, session_manager: "SessionManager | None" = None):
+ def __init__(self, config: Config, bus: MessageBus):
self.config = config
self.bus = bus
- self.session_manager = session_manager
self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None
@@ -46,7 +42,6 @@ class ChannelManager:
self.config.channels.telegram,
self.bus,
groq_api_key=self.config.providers.groq.api_key,
- session_manager=self.session_manager,
)
logger.info("Telegram channel enabled")
except ImportError as e:
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 1abd600..32f8c67 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -4,8 +4,6 @@ from __future__ import annotations
import asyncio
import re
-from typing import TYPE_CHECKING
-
from loguru import logger
from telegram import BotCommand, Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
@@ -16,9 +14,6 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import TelegramConfig
-if TYPE_CHECKING:
- from nanobot.session.manager import SessionManager
-
def _markdown_to_telegram_html(text: str) -> str:
"""
@@ -95,7 +90,7 @@ class TelegramChannel(BaseChannel):
# Commands registered with Telegram's command menu
BOT_COMMANDS = [
BotCommand("start", "Start the bot"),
- BotCommand("reset", "Reset conversation history"),
+ BotCommand("new", "Start a new conversation"),
BotCommand("help", "Show available commands"),
]
@@ -104,12 +99,10 @@ class TelegramChannel(BaseChannel):
config: TelegramConfig,
bus: MessageBus,
groq_api_key: str = "",
- session_manager: SessionManager | None = None,
):
super().__init__(config, bus)
self.config: TelegramConfig = config
self.groq_api_key = groq_api_key
- self.session_manager = session_manager
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
@@ -132,8 +125,8 @@ class TelegramChannel(BaseChannel):
# Add command handlers
self._app.add_handler(CommandHandler("start", self._on_start))
- self._app.add_handler(CommandHandler("reset", self._on_reset))
- self._app.add_handler(CommandHandler("help", self._on_help))
+ self._app.add_handler(CommandHandler("new", self._forward_command))
+ self._app.add_handler(CommandHandler("help", self._forward_command))
# Add message handler for text, photos, voice, documents
self._app.add_handler(
@@ -229,40 +222,15 @@ class TelegramChannel(BaseChannel):
"Type /help to see available commands."
)
- async def _on_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle /reset command — clear conversation history."""
+ 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
-
- chat_id = str(update.message.chat_id)
- session_key = f"{self.name}:{chat_id}"
-
- if self.session_manager is None:
- logger.warning("/reset called but session_manager is not available")
- await update.message.reply_text("⚠️ Session management is not available.")
- return
-
- session = self.session_manager.get_or_create(session_key)
- msg_count = len(session.messages)
- session.clear()
- self.session_manager.save(session)
-
- logger.info(f"Session reset for {session_key} (cleared {msg_count} messages)")
- await update.message.reply_text("🔄 Conversation history cleared. Let's start fresh!")
-
- async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle /help command — show available commands."""
- if not update.message:
- return
-
- help_text = (
- "🐈 nanobot commands\n\n"
- "/start — Start the bot\n"
- "/reset — Reset conversation history\n"
- "/help — Show this help message\n\n"
- "Just send me a text message to chat!"
+ await self._handle_message(
+ sender_id=str(update.effective_user.id),
+ chat_id=str(update.message.chat_id),
+ content=update.message.text,
)
- await update.message.reply_text(help_text, parse_mode="HTML")
async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming messages (text, photos, voice, documents)."""
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index eb16782..4580fed 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -28,7 +28,6 @@ app = typer.Typer(
console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
-NEW_SESSION_COMMANDS = {"/new", "/reset"}
# ---------------------------------------------------------------------------
# CLI input: prompt_toolkit for editing, paste, history, and display
@@ -112,11 +111,6 @@ def _is_exit_command(command: str) -> bool:
return command.lower() in EXIT_COMMANDS
-def _is_new_session_command(command: str) -> bool:
- """Return True when input should clear the session history."""
- return command.lower() in NEW_SESSION_COMMANDS
-
-
async def _read_interactive_input_async() -> str:
"""Read user input using prompt_toolkit (handles paste, history, display).
@@ -375,7 +369,7 @@ def gateway(
)
# Create channel manager
- channels = ChannelManager(config, bus, session_manager=session_manager)
+ channels = ChannelManager(config, bus)
if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
@@ -490,15 +484,6 @@ def agent(
console.print("\nGoodbye!")
break
- if _is_new_session_command(command):
- session = agent_loop.sessions.get_or_create(session_id)
- session.clear()
- agent_loop.sessions.save(session)
- console.print(
- f"\n[green]{__logo__} Started new session. History cleared.[/green]\n"
- )
- continue
-
with _thinking_ctx():
response = await agent_loop.process_direct(user_input, session_id)
_print_agent_response(response, render_markdown=markdown)
From 32c94311918fc05b63918ade223a10b9ceaa7197 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 13 Feb 2026 04:13:16 +0000
Subject: [PATCH 087/506] fix: align CLI session_id default to "cli:direct" for
backward compatibility
---
nanobot/cli/commands.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 4580fed..3158d29 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -410,7 +410,7 @@ def gateway(
@app.command()
def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
- session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
+ session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
From e1c359a198639eae3619c375bccb15f665c254ea Mon Sep 17 00:00:00 2001
From: Ahwei
Date: Fri, 13 Feb 2026 12:29:45 +0800
Subject: [PATCH 088/506] chore: add venv/ to .gitignore
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 36dbfc2..66dbe8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ docs/
*.pywz
*.pyzz
.venv/
+venv/
__pycache__/
poetry.lock
.pytest_cache/
From fd7e477b188638f666882bf29872ca44e8c38a34 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 13 Feb 2026 05:37:56 +0000
Subject: [PATCH 089/506] fix(security): bind WhatsApp bridge to localhost +
optional token auth
---
SECURITY.md | 6 +--
bridge/src/index.ts | 3 +-
bridge/src/server.ts | 79 ++++++++++++++++++++++++------------
nanobot/channels/whatsapp.py | 3 ++
nanobot/cli/commands.py | 8 +++-
nanobot/config/schema.py | 1 +
6 files changed, 68 insertions(+), 32 deletions(-)
diff --git a/SECURITY.md b/SECURITY.md
index ac15ba4..af3448c 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -95,8 +95,8 @@ File operations have path traversal protection, but:
- Consider using a firewall to restrict outbound connections if needed
**WhatsApp Bridge:**
-- The bridge runs on `localhost:3001` by default
-- If exposing to network, use proper authentication and TLS
+- 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
@@ -224,7 +224,7 @@ If you suspect a security breach:
✅ **Secure Communication**
- HTTPS for all external API calls
- TLS for Telegram API
-- WebSocket security for WhatsApp bridge
+- WhatsApp bridge: localhost-only binding + optional token auth
## Known Limitations
diff --git a/bridge/src/index.ts b/bridge/src/index.ts
index 8db63ef..e8f3db9 100644
--- a/bridge/src/index.ts
+++ b/bridge/src/index.ts
@@ -25,11 +25,12 @@ import { join } from 'path';
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
+const TOKEN = process.env.BRIDGE_TOKEN || undefined;
console.log('🐈 nanobot WhatsApp Bridge');
console.log('========================\n');
-const server = new BridgeServer(PORT, AUTH_DIR);
+const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
// Handle graceful shutdown
process.on('SIGINT', async () => {
diff --git a/bridge/src/server.ts b/bridge/src/server.ts
index c6fd599..7d48f5e 100644
--- a/bridge/src/server.ts
+++ b/bridge/src/server.ts
@@ -1,5 +1,6 @@
/**
* 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';
@@ -21,12 +22,13 @@ export class BridgeServer {
private wa: WhatsAppClient | null = null;
private clients: Set = new Set();
- constructor(private port: number, private authDir: string) {}
+ constructor(private port: number, private authDir: string, private token?: string) {}
async start(): Promise {
- // Create WebSocket server
- this.wss = new WebSocketServer({ port: this.port });
- console.log(`🌉 Bridge server listening on ws://localhost:${this.port}`);
+ // 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({
@@ -38,35 +40,58 @@ export class BridgeServer {
// Handle WebSocket connections
this.wss.on('connection', (ws) => {
- console.log('🔗 Python client connected');
- 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);
- });
+ 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);
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 6e00e9d..0cf2dd7 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -42,6 +42,9 @@ class WhatsAppChannel(BaseChannel):
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")
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 3158d29..92c017e 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -636,14 +636,20 @@ def _get_bridge_dir() -> Path:
def channels_login():
"""Link device via QR code."""
import subprocess
+ from nanobot.config.loader import load_config
+ config = load_config()
bridge_dir = _get_bridge_dir()
console.print(f"{__logo__} Starting bridge...")
console.print("Scan the QR code to connect.\n")
+ 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)
+ 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:
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index fdf1868..ef999b7 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -9,6 +9,7 @@ class WhatsAppConfig(BaseModel):
"""WhatsApp channel configuration."""
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
From 202f0a3144dfa454f9de862163251a45e8f1e2ac Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 13 Feb 2026 06:17:22 +0000
Subject: [PATCH 090/506] bump: 0.1.3.post7
---
README.md | 2 +-
pyproject.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index f5d3e7c..cac34e0 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,578 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/pyproject.toml b/pyproject.toml
index b1b3c81..80e54c8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
-version = "0.1.3.post6"
+version = "0.1.3.post7"
description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11"
license = {text = "MIT"}
From 43e2f2605b0b0654f0e8e7cdbaf8e839aa08ca33 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 13 Feb 2026 06:26:12 +0000
Subject: [PATCH 091/506] docs: update v0.1.3.post7 news
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index cac34e0..207df82 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
## 📢 News
+- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
From 740294fd748b6634bdf22e56b0383c5cec9e3ce2 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Fri, 13 Feb 2026 15:10:07 +0800
Subject: [PATCH 092/506] fix: history messages should not be change[kvcache]
---
.gitignore | 1 -
nanobot/agent/loop.py | 281 +++++++++++++++----------------
nanobot/session/manager.py | 79 ++++-----
tests/test_cli_input.py | 3 +-
tests/test_consolidate_offset.py | 159 +++++++++++++++++
5 files changed, 336 insertions(+), 187 deletions(-)
create mode 100644 tests/test_consolidate_offset.py
diff --git a/.gitignore b/.gitignore
index 36dbfc2..fd59029 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,5 +17,4 @@ docs/
__pycache__/
poetry.lock
.pytest_cache/
-tests/
botpy.log
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 80aeac4..8f6ef78 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -2,6 +2,7 @@
import asyncio
import json
+from datetime import datetime
from pathlib import Path
from typing import Any
@@ -26,7 +27,7 @@ from nanobot.session.manager import SessionManager
class AgentLoop:
"""
The agent loop is the core processing engine.
-
+
It:
1. Receives messages from the bus
2. Builds context with history, memory, skills
@@ -34,7 +35,7 @@ class AgentLoop:
4. Executes tool calls
5. Sends responses back
"""
-
+
def __init__(
self,
bus: MessageBus,
@@ -61,8 +62,10 @@ class AgentLoop:
self.exec_config = exec_config or ExecToolConfig()
self.cron_service = cron_service
self.restrict_to_workspace = restrict_to_workspace
-
+
self.context = ContextBuilder(workspace)
+
+ # Initialize session manager
self.sessions = session_manager or SessionManager(workspace)
self.tools = ToolRegistry()
self.subagents = SubagentManager(
@@ -110,11 +113,81 @@ class AgentLoop:
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
+ def _set_tool_context(self, channel: str, chat_id: str) -> None:
+ """Update context for all tools that need routing info."""
+ if message_tool := self.tools.get("message"):
+ if isinstance(message_tool, MessageTool):
+ message_tool.set_context(channel, chat_id)
+
+ if spawn_tool := self.tools.get("spawn"):
+ if isinstance(spawn_tool, SpawnTool):
+ spawn_tool.set_context(channel, chat_id)
+
+ if cron_tool := self.tools.get("cron"):
+ if isinstance(cron_tool, CronTool):
+ cron_tool.set_context(channel, chat_id)
+
+ async def _run_agent_loop(self, initial_messages: list[dict]) -> tuple[str | None, list[str]]:
+ """
+ Run the agent iteration loop.
+
+ Args:
+ initial_messages: Starting messages for the LLM conversation.
+
+ Returns:
+ Tuple of (final_content, list_of_tools_used).
+ """
+ messages = initial_messages
+ iteration = 0
+ final_content = None
+ tools_used: list[str] = []
+
+ while iteration < self.max_iterations:
+ iteration += 1
+
+ response = await self.provider.chat(
+ messages=messages,
+ tools=self.tools.get_definitions(),
+ model=self.model
+ )
+
+ if response.has_tool_calls:
+ tool_call_dicts = [
+ {
+ "id": tc.id,
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": json.dumps(tc.arguments)
+ }
+ }
+ for tc in response.tool_calls
+ ]
+ 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(f"Tool call: {tool_call.name}({args_str[:200]})")
+ result = await self.tools.execute(tool_call.name, tool_call.arguments)
+ messages = self.context.add_tool_result(
+ messages, tool_call.id, tool_call.name, result
+ )
+ messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
+ else:
+ final_content = response.content
+ break
+
+ return final_content, tools_used
+
async def run(self) -> None:
"""Run the agent loop, processing messages from the bus."""
self._running = True
logger.info("Agent loop started")
-
+
while self._running:
try:
# Wait for next message
@@ -173,8 +246,10 @@ class AgentLoop:
await self._consolidate_memory(session, archive_all=True)
session.clear()
self.sessions.save(session)
+ # Clear cache to force reload from disk on next request
+ self.sessions._cache.pop(session.key, None)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
- content="🐈 New session started. Memory consolidated.")
+ content="New session started. Memory consolidated.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
@@ -182,79 +257,22 @@ class AgentLoop:
# Consolidate memory before processing if session is too large
if len(session.messages) > self.memory_window:
await self._consolidate_memory(session)
-
+
# Update tool contexts
- message_tool = self.tools.get("message")
- if isinstance(message_tool, MessageTool):
- message_tool.set_context(msg.channel, msg.chat_id)
-
- spawn_tool = self.tools.get("spawn")
- if isinstance(spawn_tool, SpawnTool):
- spawn_tool.set_context(msg.channel, msg.chat_id)
-
- cron_tool = self.tools.get("cron")
- if isinstance(cron_tool, CronTool):
- cron_tool.set_context(msg.channel, msg.chat_id)
-
- # Build initial messages (use get_history for LLM-formatted messages)
- messages = self.context.build_messages(
+ self._set_tool_context(msg.channel, msg.chat_id)
+
+ # Build initial messages
+ initial_messages = self.context.build_messages(
history=session.get_history(),
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel,
chat_id=msg.chat_id,
)
-
- # Agent loop
- iteration = 0
- final_content = None
- tools_used: list[str] = []
-
- while iteration < self.max_iterations:
- iteration += 1
-
- # Call LLM
- response = await self.provider.chat(
- messages=messages,
- tools=self.tools.get_definitions(),
- model=self.model
- )
-
- # Handle tool calls
- if response.has_tool_calls:
- # Add assistant message with tool calls
- tool_call_dicts = [
- {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments) # Must be JSON string
- }
- }
- for tc in response.tool_calls
- ]
- messages = self.context.add_assistant_message(
- messages, response.content, tool_call_dicts,
- reasoning_content=response.reasoning_content,
- )
-
- # Execute tools
- 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(f"Tool call: {tool_call.name}({args_str[:200]})")
- result = await self.tools.execute(tool_call.name, tool_call.arguments)
- messages = self.context.add_tool_result(
- messages, tool_call.id, tool_call.name, result
- )
- # Interleaved CoT: reflect before next action
- messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
- else:
- # No tool calls, we're done
- final_content = response.content
- break
-
+
+ # Run agent loop
+ final_content, tools_used = await self._run_agent_loop(initial_messages)
+
if final_content is None:
final_content = "I've completed processing but have no response to give."
@@ -297,71 +315,21 @@ class AgentLoop:
# Use the origin session for context
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
-
+
# Update tool contexts
- message_tool = self.tools.get("message")
- if isinstance(message_tool, MessageTool):
- message_tool.set_context(origin_channel, origin_chat_id)
-
- spawn_tool = self.tools.get("spawn")
- if isinstance(spawn_tool, SpawnTool):
- spawn_tool.set_context(origin_channel, origin_chat_id)
-
- cron_tool = self.tools.get("cron")
- if isinstance(cron_tool, CronTool):
- cron_tool.set_context(origin_channel, origin_chat_id)
-
+ self._set_tool_context(origin_channel, origin_chat_id)
+
# Build messages with the announce content
- messages = self.context.build_messages(
+ initial_messages = self.context.build_messages(
history=session.get_history(),
current_message=msg.content,
channel=origin_channel,
chat_id=origin_chat_id,
)
-
- # Agent loop (limited for announce handling)
- iteration = 0
- final_content = None
-
- while iteration < self.max_iterations:
- iteration += 1
-
- response = await self.provider.chat(
- messages=messages,
- tools=self.tools.get_definitions(),
- model=self.model
- )
-
- if response.has_tool_calls:
- tool_call_dicts = [
- {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments)
- }
- }
- for tc in response.tool_calls
- ]
- messages = self.context.add_assistant_message(
- messages, response.content, tool_call_dicts,
- reasoning_content=response.reasoning_content,
- )
-
- for tool_call in response.tool_calls:
- args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
- logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
- result = await self.tools.execute(tool_call.name, tool_call.arguments)
- messages = self.context.add_tool_result(
- messages, tool_call.id, tool_call.name, result
- )
- # Interleaved CoT: reflect before next action
- messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
- else:
- final_content = response.content
- break
-
+
+ # Run agent loop
+ final_content, _ = await self._run_agent_loop(initial_messages)
+
if final_content is None:
final_content = "Background task completed."
@@ -377,19 +345,39 @@ class AgentLoop:
)
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
- """Consolidate old messages into MEMORY.md + HISTORY.md, then trim session."""
- if not session.messages:
- return
+ """Consolidate old messages into MEMORY.md + HISTORY.md.
+
+ Args:
+ archive_all: If True, clear all messages and reset session (for /new command).
+ If False, only write to files without modifying session.
+ """
memory = MemoryStore(self.workspace)
+
+ # Handle /new command: clear session and consolidate everything
if archive_all:
- old_messages = session.messages
- keep_count = 0
+ old_messages = session.messages # All messages
+ keep_count = 0 # Clear everything
+ logger.info(f"Memory consolidation (archive_all): {len(session.messages)} total messages archived")
else:
- keep_count = min(10, max(2, self.memory_window // 2))
- old_messages = session.messages[:-keep_count]
- if not old_messages:
- return
- logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
+ # Normal consolidation: only write files, keep session intact
+ keep_count = self.memory_window // 2
+
+ # Check if consolidation is needed
+ if len(session.messages) <= keep_count:
+ logger.debug(f"Session {session.key}: No consolidation needed (messages={len(session.messages)}, keep={keep_count})")
+ return
+
+ # Use last_consolidated to avoid re-processing messages
+ messages_to_process = len(session.messages) - session.last_consolidated
+ if messages_to_process <= 0:
+ logger.debug(f"Session {session.key}: No new messages to consolidate (last_consolidated={session.last_consolidated}, total={len(session.messages)})")
+ return
+
+ # Get messages to consolidate (from last_consolidated to keep_count from end)
+ old_messages = session.messages[session.last_consolidated:-keep_count]
+ if not old_messages:
+ return
+ logger.info(f"Memory consolidation started: {len(session.messages)} total, {len(old_messages)} new to consolidate, {keep_count} keep")
# Format messages for LLM (include tool names when available)
lines = []
@@ -434,9 +422,18 @@ Respond with ONLY valid JSON, no markdown fences."""
if update != current_memory:
memory.write_long_term(update)
- session.messages = session.messages[-keep_count:] if keep_count else []
- self.sessions.save(session)
- logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages")
+ # Update last_consolidated to track what's been processed
+ if archive_all:
+ # /new command: reset to 0 after clearing
+ session.last_consolidated = 0
+ else:
+ # Normal: mark up to (total - keep_count) as consolidated
+ session.last_consolidated = len(session.messages) - keep_count
+
+ # Key: We do NOT modify session.messages (append-only for cache)
+ # The consolidation is only for human-readable files (MEMORY.md/HISTORY.md)
+ # LLM cache remains intact because the messages list is unchanged
+ logger.info(f"Memory consolidation done: {len(session.messages)} total messages (unchanged), last_consolidated={session.last_consolidated}")
except Exception as e:
logger.error(f"Memory consolidation failed: {e}")
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index cd25019..9549fd2 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -15,15 +15,20 @@ from nanobot.utils.helpers import ensure_dir, safe_filename
class Session:
"""
A conversation session.
-
+
Stores messages in JSONL format for easy reading and persistence.
+
+ Important: Messages are append-only for LLM cache efficiency.
+ The consolidation process writes summaries to MEMORY.md/HISTORY.md
+ but does NOT modify the messages list or get_history() output.
"""
-
+
key: str # channel:chat_id
messages: list[dict[str, Any]] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
+ last_consolidated: int = 0 # Number of messages already consolidated to files
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the session."""
@@ -39,32 +44,36 @@ class Session:
def get_history(self, max_messages: int = 50) -> list[dict[str, Any]]:
"""
Get message history for LLM context.
-
+
+ Messages are returned in append-only order for cache efficiency.
+ Only the most recent max_messages are returned, but the order
+ is always stable for the same max_messages value.
+
Args:
- max_messages: Maximum messages to return.
-
+ max_messages: Maximum messages to return (most recent).
+
Returns:
- List of messages in LLM format.
+ List of messages in LLM format (role and content only).
"""
- # Get recent messages
- recent = self.messages[-max_messages:] if len(self.messages) > max_messages else self.messages
-
+ recent = self.messages[-max_messages:]
+
# Convert to LLM format (just role and content)
return [{"role": m["role"], "content": m["content"]} for m in recent]
def clear(self) -> None:
- """Clear all messages in the session."""
+ """Clear all messages and reset session to initial state."""
self.messages = []
+ self.last_consolidated = 0
self.updated_at = datetime.now()
class SessionManager:
"""
Manages conversation sessions.
-
+
Sessions are stored as JSONL files in the sessions directory.
"""
-
+
def __init__(self, workspace: Path):
self.workspace = workspace
self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions")
@@ -100,34 +109,37 @@ class SessionManager:
def _load(self, key: str) -> Session | None:
"""Load a session from disk."""
path = self._get_session_path(key)
-
+
if not path.exists():
return None
-
+
try:
messages = []
metadata = {}
created_at = None
-
+ last_consolidated = 0
+
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
-
+
data = json.loads(line)
-
+
if data.get("_type") == "metadata":
metadata = data.get("metadata", {})
created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None
+ last_consolidated = data.get("last_consolidated", 0)
else:
messages.append(data)
-
+
return Session(
key=key,
messages=messages,
created_at=created_at or datetime.now(),
- metadata=metadata
+ metadata=metadata,
+ last_consolidated=last_consolidated
)
except Exception as e:
logger.warning(f"Failed to load session {key}: {e}")
@@ -136,43 +148,24 @@ class SessionManager:
def save(self, session: Session) -> None:
"""Save a session to disk."""
path = self._get_session_path(session.key)
-
+
with open(path, "w") as f:
# Write metadata first
metadata_line = {
"_type": "metadata",
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
- "metadata": session.metadata
+ "metadata": session.metadata,
+ "last_consolidated": session.last_consolidated
}
f.write(json.dumps(metadata_line) + "\n")
-
+
# Write messages
for msg in session.messages:
f.write(json.dumps(msg) + "\n")
-
+
self._cache[session.key] = session
- def delete(self, key: str) -> bool:
- """
- Delete a session.
-
- Args:
- key: Session key.
-
- Returns:
- True if deleted, False if not found.
- """
- # Remove from cache
- self._cache.pop(key, None)
-
- # Remove file
- path = self._get_session_path(key)
- if path.exists():
- path.unlink()
- return True
- return False
-
def list_sessions(self) -> list[dict[str, Any]]:
"""
List all sessions.
diff --git a/tests/test_cli_input.py b/tests/test_cli_input.py
index 6f9c257..9626120 100644
--- a/tests/test_cli_input.py
+++ b/tests/test_cli_input.py
@@ -12,7 +12,8 @@ def mock_prompt_session():
"""Mock the global prompt session."""
mock_session = MagicMock()
mock_session.prompt_async = AsyncMock()
- with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session):
+ with patch("nanobot.cli.commands._PROMPT_SESSION", mock_session), \
+ patch("nanobot.cli.commands.patch_stdout"):
yield mock_session
diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py
new file mode 100644
index 0000000..dfac395
--- /dev/null
+++ b/tests/test_consolidate_offset.py
@@ -0,0 +1,159 @@
+"""Test session management with cache-friendly message handling."""
+
+import pytest
+from pathlib import Path
+from typing import Callable
+from nanobot.session.manager import Session, SessionManager
+
+
+class TestSessionLastConsolidated:
+ """Test last_consolidated tracking to avoid duplicate processing."""
+
+ def test_initial_last_consolidated_zero(self) -> None:
+ """Test that new session starts with last_consolidated=0."""
+ session = Session(key="test:initial")
+ assert session.last_consolidated == 0
+
+ def test_last_consolidated_persistence(self, tmp_path) -> None:
+ """Test that last_consolidated persists across save/load."""
+ manager = SessionManager(Path(tmp_path))
+
+ session1 = Session(key="test:persist")
+ for i in range(20):
+ session1.add_message("user", f"msg{i}")
+ session1.last_consolidated = 15 # Simulate consolidation
+ manager.save(session1)
+
+ session2 = manager.get_or_create("test:persist")
+ assert session2.last_consolidated == 15
+ assert len(session2.messages) == 20
+
+ def test_clear_resets_last_consolidated(self) -> None:
+ """Test that clear() resets last_consolidated to 0."""
+ session = Session(key="test:clear")
+ for i in range(10):
+ session.add_message("user", f"msg{i}")
+ session.last_consolidated = 5
+
+ session.clear()
+ assert len(session.messages) == 0
+ assert session.last_consolidated == 0
+
+
+class TestSessionImmutableHistory:
+ """Test Session message immutability for cache efficiency."""
+
+ def test_initial_state(self) -> None:
+ """Test that new session has empty messages list."""
+ session = Session(key="test:initial")
+ assert len(session.messages) == 0
+
+ def test_add_messages_appends_only(self) -> None:
+ """Test that adding messages only appends, never modifies."""
+ session = Session(key="test:preserve")
+ session.add_message("user", "msg1")
+ session.add_message("assistant", "resp1")
+ session.add_message("user", "msg2")
+ assert len(session.messages) == 3
+ # First message should always be the first message added
+ assert session.messages[0]["content"] == "msg1"
+
+ def test_get_history_returns_most_recent(self) -> None:
+ """Test get_history returns the most recent messages."""
+ session = Session(key="test:history")
+ for i in range(10):
+ session.add_message("user", f"msg{i}")
+ session.add_message("assistant", f"resp{i}")
+ history = session.get_history(max_messages=6)
+ # Should return last 6 messages
+ assert len(history) == 6
+ # First returned should be resp4 (messages 7-12: msg7/resp7, msg8/resp8, msg9/resp9)
+ # Actually: 20 messages total, last 6 are indices 14-19
+ assert history[0]["content"] == "msg7" # Index 14 (7th user msg after 7 pairs)
+ assert history[-1]["content"] == "resp9" # Index 19 (last assistant msg)
+
+ def test_get_history_with_all_messages(self) -> None:
+ """Test get_history with max_messages larger than actual."""
+ session = Session(key="test:all")
+ for i in range(5):
+ session.add_message("user", f"msg{i}")
+ history = session.get_history(max_messages=100)
+ assert len(history) == 5
+ assert history[0]["content"] == "msg0"
+
+ def test_get_history_stable_for_same_session(self) -> None:
+ """Test that get_history returns same content for same max_messages."""
+ session = Session(key="test:stable")
+ for i in range(20):
+ session.add_message("user", f"msg{i}")
+
+ # Multiple calls with same max_messages should return identical content
+ history1 = session.get_history(max_messages=10)
+ history2 = session.get_history(max_messages=10)
+ assert history1 == history2
+
+ def test_messages_list_never_modified(self) -> None:
+ """Test that messages list is never modified after creation."""
+ session = Session(key="test:immutable")
+ original_len = 0
+
+ # Add some messages
+ for i in range(5):
+ session.add_message("user", f"msg{i}")
+ original_len += 1
+
+ assert len(session.messages) == original_len
+
+ # get_history should not modify the list
+ session.get_history(max_messages=2)
+ assert len(session.messages) == original_len
+
+ # Multiple calls should not affect messages
+ for _ in range(10):
+ session.get_history(max_messages=3)
+ assert len(session.messages) == original_len
+
+
+class TestSessionPersistence:
+ """Test Session persistence and reload."""
+
+ @pytest.fixture
+ def temp_manager(self, tmp_path):
+ return SessionManager(Path(tmp_path))
+
+ def test_persistence_roundtrip(self, temp_manager):
+ """Test that messages persist across save/load."""
+ session1 = Session(key="test:persistence")
+ for i in range(20):
+ session1.add_message("user", f"msg{i}")
+ temp_manager.save(session1)
+
+ session2 = temp_manager.get_or_create("test:persistence")
+ assert len(session2.messages) == 20
+ assert session2.messages[0]["content"] == "msg0"
+ assert session2.messages[-1]["content"] == "msg19"
+
+ def test_get_history_after_reload(self, temp_manager):
+ """Test that get_history works correctly after reload."""
+ session1 = Session(key="test:reload")
+ for i in range(30):
+ session1.add_message("user", f"msg{i}")
+ temp_manager.save(session1)
+
+ session2 = temp_manager.get_or_create("test:reload")
+ history = session2.get_history(max_messages=10)
+ # Should return last 10 messages (indices 20-29)
+ assert len(history) == 10
+ assert history[0]["content"] == "msg20"
+ assert history[-1]["content"] == "msg29"
+
+ def test_clear_resets_session(self, temp_manager):
+ """Test that clear() properly resets session."""
+ session = Session(key="test:clear")
+ for i in range(10):
+ session.add_message("user", f"msg{i}")
+ assert len(session.messages) == 10
+
+ session.clear()
+ assert len(session.messages) == 0
+
From ccf9a6c1463c1aca974327c5388c105970ced4e2 Mon Sep 17 00:00:00 2001
From: Ahwei
Date: Fri, 13 Feb 2026 15:31:30 +0800
Subject: [PATCH 093/506] fix(feishu): convert markdown headings to div
elements in card messages
Markdown heading syntax (#) is not properly rendered in Feishu interactive
cards. Convert headings to div elements with lark_md format (bold text) for
proper display.
- Add _HEADING_RE regex to match markdown headings (h1-h6)
- Add _split_headings() method to parse and convert headings to div elements
- Update _build_card_elements() to process headings before markdown content
---
nanobot/channels/feishu.py | 49 ++++++++++++++++++++++++++++++++++----
1 file changed, 44 insertions(+), 5 deletions(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 23d1415..9017b40 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -166,6 +166,10 @@ class FeishuChannel(BaseChannel):
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."""
@@ -185,17 +189,52 @@ class FeishuChannel(BaseChannel):
}
def _build_card_elements(self, content: str) -> list[dict]:
- """Split content into markdown + table elements for Feishu card."""
+ """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()].strip()
- if before:
- elements.append({"tag": "markdown", "content": before})
+ 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:].strip()
+ 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})
+ level = len(m.group(1))
+ 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}]
async def send(self, msg: OutboundMessage) -> None:
From 98a762452a8d6e2f792bad084539058b42ceaffb Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Fri, 13 Feb 2026 15:14:22 +0800
Subject: [PATCH 094/506] fix: useasyncio.create_task to avoid block
---
nanobot/agent/loop.py | 19 +++++++++++++++----
1 file changed, 15 insertions(+), 4 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 8f6ef78..80abae1 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -21,7 +21,7 @@ from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager
-from nanobot.session.manager import SessionManager
+from nanobot.session.manager import Session, SessionManager
class AgentLoop:
@@ -243,20 +243,31 @@ class AgentLoop:
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
- await self._consolidate_memory(session, archive_all=True)
+ # Capture messages before clearing (avoid race condition with background task)
+ messages_to_archive = session.messages.copy()
session.clear()
self.sessions.save(session)
# Clear cache to force reload from disk on next request
self.sessions._cache.pop(session.key, None)
+
+ # Consolidate in background (non-blocking)
+ async def _consolidate_and_cleanup():
+ # Create a temporary session with archived messages
+ temp_session = Session(key=session.key)
+ temp_session.messages = messages_to_archive
+ await self._consolidate_memory(temp_session, archive_all=True)
+
+ asyncio.create_task(_consolidate_and_cleanup())
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
- content="New session started. Memory consolidated.")
+ content="New session started. Memory consolidation in progress.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
# Consolidate memory before processing if session is too large
+ # Run in background to avoid blocking main conversation
if len(session.messages) > self.memory_window:
- await self._consolidate_memory(session)
+ asyncio.create_task(self._consolidate_memory(session))
# Update tool contexts
self._set_tool_context(msg.channel, msg.chat_id)
From afc8d5065962f053204aa19a649966ad07841313 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Fri, 13 Feb 2026 16:30:43 +0800
Subject: [PATCH 095/506] test: add comprehensive tests for consolidate offset
functionality
Add 26 new test cases covering:
- Consolidation trigger conditions (exceed window, within keep count, no new messages)
- last_consolidated edge cases (exceeds message count, negative value, new messages after consolidation)
- archive_all mode (/new command behavior)
- Cache immutability (messages list never modified during consolidation)
- Slice logic (messages[last_consolidated:-keep_count])
- Empty and boundary sessions (empty, single message, exact keep count, very large)
Refactor tests with helper functions to reduce code duplication by 25%:
- create_session_with_messages() - creates session with specified message count
- assert_messages_content() - validates message content range
- get_old_messages() - encapsulates standard slice logic
All 35 tests passing.
---
tests/test_consolidate_offset.py | 406 +++++++++++++++++++++++++++----
1 file changed, 362 insertions(+), 44 deletions(-)
diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py
index dfac395..e204733 100644
--- a/tests/test_consolidate_offset.py
+++ b/tests/test_consolidate_offset.py
@@ -2,9 +2,56 @@
import pytest
from pathlib import Path
-from typing import Callable
from nanobot.session.manager import Session, SessionManager
+# Test constants
+MEMORY_WINDOW = 50
+KEEP_COUNT = MEMORY_WINDOW // 2 # 25
+
+
+def create_session_with_messages(key: str, count: int, role: str = "user") -> Session:
+ """Create a session and add the specified number of messages.
+
+ Args:
+ key: Session identifier
+ count: Number of messages to add
+ role: Message role (default: "user")
+
+ Returns:
+ Session with the specified messages
+ """
+ session = Session(key=key)
+ for i in range(count):
+ session.add_message(role, f"msg{i}")
+ return session
+
+
+def assert_messages_content(messages: list, start_index: int, end_index: int) -> None:
+ """Assert that messages contain expected content from start to end index.
+
+ Args:
+ messages: List of message dictionaries
+ start_index: Expected first message index
+ end_index: Expected last message index
+ """
+ assert len(messages) > 0
+ assert messages[0]["content"] == f"msg{start_index}"
+ assert messages[-1]["content"] == f"msg{end_index}"
+
+
+def get_old_messages(session: Session, last_consolidated: int, keep_count: int) -> list:
+ """Extract messages that would be consolidated using the standard slice logic.
+
+ Args:
+ session: The session containing messages
+ last_consolidated: Index of last consolidated message
+ keep_count: Number of recent messages to keep
+
+ Returns:
+ List of messages that would be consolidated
+ """
+ return session.messages[last_consolidated:-keep_count]
+
class TestSessionLastConsolidated:
"""Test last_consolidated tracking to avoid duplicate processing."""
@@ -17,11 +64,8 @@ class TestSessionLastConsolidated:
def test_last_consolidated_persistence(self, tmp_path) -> None:
"""Test that last_consolidated persists across save/load."""
manager = SessionManager(Path(tmp_path))
-
- session1 = Session(key="test:persist")
- for i in range(20):
- session1.add_message("user", f"msg{i}")
- session1.last_consolidated = 15 # Simulate consolidation
+ session1 = create_session_with_messages("test:persist", 20)
+ session1.last_consolidated = 15
manager.save(session1)
session2 = manager.get_or_create("test:persist")
@@ -30,9 +74,7 @@ class TestSessionLastConsolidated:
def test_clear_resets_last_consolidated(self) -> None:
"""Test that clear() resets last_consolidated to 0."""
- session = Session(key="test:clear")
- for i in range(10):
- session.add_message("user", f"msg{i}")
+ session = create_session_with_messages("test:clear", 10)
session.last_consolidated = 5
session.clear()
@@ -55,7 +97,6 @@ class TestSessionImmutableHistory:
session.add_message("assistant", "resp1")
session.add_message("user", "msg2")
assert len(session.messages) == 3
- # First message should always be the first message added
assert session.messages[0]["content"] == "msg1"
def test_get_history_returns_most_recent(self) -> None:
@@ -64,51 +105,34 @@ class TestSessionImmutableHistory:
for i in range(10):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
+
history = session.get_history(max_messages=6)
- # Should return last 6 messages
assert len(history) == 6
- # First returned should be resp4 (messages 7-12: msg7/resp7, msg8/resp8, msg9/resp9)
- # Actually: 20 messages total, last 6 are indices 14-19
- assert history[0]["content"] == "msg7" # Index 14 (7th user msg after 7 pairs)
- assert history[-1]["content"] == "resp9" # Index 19 (last assistant msg)
+ assert history[0]["content"] == "msg7"
+ assert history[-1]["content"] == "resp9"
def test_get_history_with_all_messages(self) -> None:
"""Test get_history with max_messages larger than actual."""
- session = Session(key="test:all")
- for i in range(5):
- session.add_message("user", f"msg{i}")
+ session = create_session_with_messages("test:all", 5)
history = session.get_history(max_messages=100)
assert len(history) == 5
assert history[0]["content"] == "msg0"
def test_get_history_stable_for_same_session(self) -> None:
"""Test that get_history returns same content for same max_messages."""
- session = Session(key="test:stable")
- for i in range(20):
- session.add_message("user", f"msg{i}")
-
- # Multiple calls with same max_messages should return identical content
+ session = create_session_with_messages("test:stable", 20)
history1 = session.get_history(max_messages=10)
history2 = session.get_history(max_messages=10)
assert history1 == history2
def test_messages_list_never_modified(self) -> None:
"""Test that messages list is never modified after creation."""
- session = Session(key="test:immutable")
- original_len = 0
+ session = create_session_with_messages("test:immutable", 5)
+ original_len = len(session.messages)
- # Add some messages
- for i in range(5):
- session.add_message("user", f"msg{i}")
- original_len += 1
-
- assert len(session.messages) == original_len
-
- # get_history should not modify the list
session.get_history(max_messages=2)
assert len(session.messages) == original_len
- # Multiple calls should not affect messages
for _ in range(10):
session.get_history(max_messages=3)
assert len(session.messages) == original_len
@@ -123,9 +147,7 @@ class TestSessionPersistence:
def test_persistence_roundtrip(self, temp_manager):
"""Test that messages persist across save/load."""
- session1 = Session(key="test:persistence")
- for i in range(20):
- session1.add_message("user", f"msg{i}")
+ session1 = create_session_with_messages("test:persistence", 20)
temp_manager.save(session1)
session2 = temp_manager.get_or_create("test:persistence")
@@ -135,25 +157,321 @@ class TestSessionPersistence:
def test_get_history_after_reload(self, temp_manager):
"""Test that get_history works correctly after reload."""
- session1 = Session(key="test:reload")
- for i in range(30):
- session1.add_message("user", f"msg{i}")
+ session1 = create_session_with_messages("test:reload", 30)
temp_manager.save(session1)
session2 = temp_manager.get_or_create("test:reload")
history = session2.get_history(max_messages=10)
- # Should return last 10 messages (indices 20-29)
assert len(history) == 10
assert history[0]["content"] == "msg20"
assert history[-1]["content"] == "msg29"
def test_clear_resets_session(self, temp_manager):
"""Test that clear() properly resets session."""
- session = Session(key="test:clear")
- for i in range(10):
- session.add_message("user", f"msg{i}")
+ session = create_session_with_messages("test:clear", 10)
assert len(session.messages) == 10
session.clear()
assert len(session.messages) == 0
+
+class TestConsolidationTriggerConditions:
+ """Test consolidation trigger conditions and logic."""
+
+ def test_consolidation_needed_when_messages_exceed_window(self):
+ """Test consolidation logic: should trigger when messages > memory_window."""
+ session = create_session_with_messages("test:trigger", 60)
+
+ total_messages = len(session.messages)
+ messages_to_process = total_messages - session.last_consolidated
+
+ assert total_messages > MEMORY_WINDOW
+ assert messages_to_process > 0
+
+ expected_consolidate_count = total_messages - KEEP_COUNT
+ assert expected_consolidate_count == 35
+
+ def test_consolidation_skipped_when_within_keep_count(self):
+ """Test consolidation skipped when total messages <= keep_count."""
+ session = create_session_with_messages("test:skip", 20)
+
+ total_messages = len(session.messages)
+ assert total_messages <= KEEP_COUNT
+
+ old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
+ assert len(old_messages) == 0
+
+ def test_consolidation_skipped_when_no_new_messages(self):
+ """Test consolidation skipped when messages_to_process <= 0."""
+ session = create_session_with_messages("test:already_consolidated", 40)
+ session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
+
+ # Add a few more messages
+ for i in range(40, 42):
+ session.add_message("user", f"msg{i}")
+
+ total_messages = len(session.messages)
+ messages_to_process = total_messages - session.last_consolidated
+ assert messages_to_process > 0
+
+ # Simulate last_consolidated catching up
+ session.last_consolidated = total_messages - KEEP_COUNT
+ old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
+ assert len(old_messages) == 0
+
+
+class TestLastConsolidatedEdgeCases:
+ """Test last_consolidated edge cases and data corruption scenarios."""
+
+ def test_last_consolidated_exceeds_message_count(self):
+ """Test behavior when last_consolidated > len(messages) (data corruption)."""
+ session = create_session_with_messages("test:corruption", 10)
+ session.last_consolidated = 20
+
+ total_messages = len(session.messages)
+ messages_to_process = total_messages - session.last_consolidated
+ assert messages_to_process <= 0
+
+ old_messages = get_old_messages(session, session.last_consolidated, 5)
+ assert len(old_messages) == 0
+
+ def test_last_consolidated_negative_value(self):
+ """Test behavior with negative last_consolidated (invalid state)."""
+ session = create_session_with_messages("test:negative", 10)
+ session.last_consolidated = -5
+
+ keep_count = 3
+ old_messages = get_old_messages(session, session.last_consolidated, keep_count)
+
+ # messages[-5:-3] with 10 messages gives indices 5,6
+ assert len(old_messages) == 2
+ assert old_messages[0]["content"] == "msg5"
+ assert old_messages[-1]["content"] == "msg6"
+
+ def test_messages_added_after_consolidation(self):
+ """Test correct behavior when new messages arrive after consolidation."""
+ session = create_session_with_messages("test:new_messages", 40)
+ session.last_consolidated = len(session.messages) - KEEP_COUNT # 15
+
+ # Add new messages after consolidation
+ for i in range(40, 50):
+ session.add_message("user", f"msg{i}")
+
+ total_messages = len(session.messages)
+ old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
+ expected_consolidate_count = total_messages - KEEP_COUNT - session.last_consolidated
+
+ assert len(old_messages) == expected_consolidate_count
+ assert_messages_content(old_messages, 15, 24)
+
+ def test_slice_behavior_when_indices_overlap(self):
+ """Test slice behavior when last_consolidated >= total - keep_count."""
+ session = create_session_with_messages("test:overlap", 30)
+ session.last_consolidated = 12
+
+ old_messages = get_old_messages(session, session.last_consolidated, 20)
+ assert len(old_messages) == 0
+
+
+class TestArchiveAllMode:
+ """Test archive_all mode (used by /new command)."""
+
+ def test_archive_all_consolidates_everything(self):
+ """Test archive_all=True consolidates all messages."""
+ session = create_session_with_messages("test:archive_all", 50)
+
+ archive_all = True
+ if archive_all:
+ old_messages = session.messages
+ assert len(old_messages) == 50
+
+ assert session.last_consolidated == 0
+
+ def test_archive_all_resets_last_consolidated(self):
+ """Test that archive_all mode resets last_consolidated to 0."""
+ session = create_session_with_messages("test:reset", 40)
+ session.last_consolidated = 15
+
+ archive_all = True
+ if archive_all:
+ session.last_consolidated = 0
+
+ assert session.last_consolidated == 0
+ assert len(session.messages) == 40
+
+ def test_archive_all_vs_normal_consolidation(self):
+ """Test difference between archive_all and normal consolidation."""
+ # Normal consolidation
+ session1 = create_session_with_messages("test:normal", 60)
+ session1.last_consolidated = len(session1.messages) - KEEP_COUNT
+
+ # archive_all mode
+ session2 = create_session_with_messages("test:all", 60)
+ session2.last_consolidated = 0
+
+ assert session1.last_consolidated == 35
+ assert len(session1.messages) == 60
+ assert session2.last_consolidated == 0
+ assert len(session2.messages) == 60
+
+
+class TestCacheImmutability:
+ """Test that consolidation doesn't modify session.messages (cache safety)."""
+
+ def test_consolidation_does_not_modify_messages_list(self):
+ """Test that consolidation leaves messages list unchanged."""
+ session = create_session_with_messages("test:immutable", 50)
+
+ original_messages = session.messages.copy()
+ original_len = len(session.messages)
+ session.last_consolidated = original_len - KEEP_COUNT
+
+ assert len(session.messages) == original_len
+ assert session.messages == original_messages
+
+ def test_get_history_does_not_modify_messages(self):
+ """Test that get_history doesn't modify messages list."""
+ session = create_session_with_messages("test:history_immutable", 40)
+ original_messages = [m.copy() for m in session.messages]
+
+ for _ in range(5):
+ history = session.get_history(max_messages=10)
+ assert len(history) == 10
+
+ assert len(session.messages) == 40
+ for i, msg in enumerate(session.messages):
+ assert msg["content"] == original_messages[i]["content"]
+
+ def test_consolidation_only_updates_last_consolidated(self):
+ """Test that consolidation only updates last_consolidated field."""
+ session = create_session_with_messages("test:field_only", 60)
+
+ original_messages = session.messages.copy()
+ original_key = session.key
+ original_metadata = session.metadata.copy()
+
+ session.last_consolidated = len(session.messages) - KEEP_COUNT
+
+ assert session.messages == original_messages
+ assert session.key == original_key
+ assert session.metadata == original_metadata
+ assert session.last_consolidated == 35
+
+
+class TestSliceLogic:
+ """Test the slice logic: messages[last_consolidated:-keep_count]."""
+
+ def test_slice_extracts_correct_range(self):
+ """Test that slice extracts the correct message range."""
+ session = create_session_with_messages("test:slice", 60)
+
+ old_messages = get_old_messages(session, 0, KEEP_COUNT)
+
+ assert len(old_messages) == 35
+ assert_messages_content(old_messages, 0, 34)
+
+ remaining = session.messages[-KEEP_COUNT:]
+ assert len(remaining) == 25
+ assert_messages_content(remaining, 35, 59)
+
+ def test_slice_with_partial_consolidation(self):
+ """Test slice when some messages already consolidated."""
+ session = create_session_with_messages("test:partial", 70)
+
+ last_consolidated = 30
+ old_messages = get_old_messages(session, last_consolidated, KEEP_COUNT)
+
+ assert len(old_messages) == 15
+ assert_messages_content(old_messages, 30, 44)
+
+ def test_slice_with_various_keep_counts(self):
+ """Test slice behavior with different keep_count values."""
+ session = create_session_with_messages("test:keep_counts", 50)
+
+ test_cases = [(10, 40), (20, 30), (30, 20), (40, 10)]
+
+ for keep_count, expected_count in test_cases:
+ old_messages = session.messages[0:-keep_count]
+ assert len(old_messages) == expected_count
+
+ def test_slice_when_keep_count_exceeds_messages(self):
+ """Test slice when keep_count > len(messages)."""
+ session = create_session_with_messages("test:exceed", 10)
+
+ old_messages = session.messages[0:-20]
+ assert len(old_messages) == 0
+
+
+class TestEmptyAndBoundarySessions:
+ """Test empty sessions and boundary conditions."""
+
+ def test_empty_session_consolidation(self):
+ """Test consolidation behavior with empty session."""
+ session = Session(key="test:empty")
+
+ assert len(session.messages) == 0
+ assert session.last_consolidated == 0
+
+ messages_to_process = len(session.messages) - session.last_consolidated
+ assert messages_to_process == 0
+
+ old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
+ assert len(old_messages) == 0
+
+ def test_single_message_session(self):
+ """Test consolidation with single message."""
+ session = Session(key="test:single")
+ session.add_message("user", "only message")
+
+ assert len(session.messages) == 1
+
+ old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
+ assert len(old_messages) == 0
+
+ def test_exactly_keep_count_messages(self):
+ """Test session with exactly keep_count messages."""
+ session = create_session_with_messages("test:exact", KEEP_COUNT)
+
+ assert len(session.messages) == KEEP_COUNT
+
+ old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
+ assert len(old_messages) == 0
+
+ def test_just_over_keep_count(self):
+ """Test session with one message over keep_count."""
+ session = create_session_with_messages("test:over", KEEP_COUNT + 1)
+
+ assert len(session.messages) == 26
+
+ old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
+ assert len(old_messages) == 1
+ assert old_messages[0]["content"] == "msg0"
+
+ def test_very_large_session(self):
+ """Test consolidation with very large message count."""
+ session = create_session_with_messages("test:large", 1000)
+
+ assert len(session.messages) == 1000
+
+ old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
+ assert len(old_messages) == 975
+ assert_messages_content(old_messages, 0, 974)
+
+ remaining = session.messages[-KEEP_COUNT:]
+ assert len(remaining) == 25
+ assert_messages_content(remaining, 975, 999)
+
+ def test_session_with_gaps_in_consolidation(self):
+ """Test session with potential gaps in consolidation history."""
+ session = create_session_with_messages("test:gaps", 50)
+ session.last_consolidated = 10
+
+ # Add more messages
+ for i in range(50, 60):
+ session.add_message("user", f"msg{i}")
+
+ old_messages = get_old_messages(session, session.last_consolidated, KEEP_COUNT)
+
+ expected_count = 60 - KEEP_COUNT - 10
+ assert len(old_messages) == expected_count
+ assert_messages_content(old_messages, 10, 34)
From 09c7e7adedb0bbd65a0910d63b8a0502da86ed98 Mon Sep 17 00:00:00 2001
From: qiupinhua
Date: Fri, 13 Feb 2026 18:37:21 +0800
Subject: [PATCH 096/506] feat: change OAuth login command for providers
---
README.md | 1 +
nanobot/cli/commands.py | 36 ++++++++++++++++++++++--------------
2 files changed, 23 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index eb2ff7f..8d0ab4d 100644
--- a/README.md
+++ b/README.md
@@ -597,6 +597,7 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
| `nanobot agent --logs` | Show runtime logs during chat |
| `nanobot gateway` | Start the gateway |
| `nanobot status` | Show status |
+| `nanobot provider login openai-codex` | OAuth login for providers |
| `nanobot channels login` | Link WhatsApp (scan QR) |
| `nanobot channels status` | Show channel status |
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 1ee2332..f2a7ee3 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -860,29 +860,37 @@ def status():
# OAuth Login
# ============================================================================
+provider_app = typer.Typer(help="Manage providers")
+app.add_typer(provider_app, name="provider")
-@app.command()
-def login(
+
+@provider_app.command("login")
+def provider_login(
provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex')"),
):
"""Authenticate with an OAuth provider."""
console.print(f"{__logo__} OAuth Login - {provider}\n")
-
+
if provider == "openai-codex":
try:
- from oauth_cli_kit import get_token as get_codex_token
-
- console.print("[cyan]Starting OpenAI Codex authentication...[/cyan]")
- console.print("A browser window will open for you to authenticate.\n")
-
- token = get_codex_token()
-
- if token and token.access:
- console.print(f"[green]✓ Successfully authenticated with OpenAI Codex![/green]")
- console.print(f"[dim]Account ID: {token.account_id}[/dim]")
- else:
+ from oauth_cli_kit import get_token, login_oauth_interactive
+ token = None
+ try:
+ token = get_token()
+ except Exception:
+ token = None
+ if not (token and token.access):
+ console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]")
+ console.print("A browser window may open for you to authenticate.\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("[green]✓ Successfully authenticated with OpenAI Codex![/green]")
+ console.print(f"[dim]Account ID: {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)
From 1ae47058d9479e2d4e0151ff15ed631a8ab649f5 Mon Sep 17 00:00:00 2001
From: qiupinhua
Date: Fri, 13 Feb 2026 18:51:30 +0800
Subject: [PATCH 097/506] fix: refactor code structure for improved readability
and maintainability
---
nanobot/cli/commands.py | 23 +-
nanobot/providers/litellm_provider.py | 3 +
nanobot/providers/openai_codex_provider.py | 12 -
uv.lock | 2385 ++++++++++++++++++++
4 files changed, 2406 insertions(+), 17 deletions(-)
create mode 100644 uv.lock
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index f2a7ee3..68f2f30 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -16,6 +16,7 @@ from rich.table import Table
from rich.text import Text
from nanobot import __version__, __logo__
+from nanobot.config.schema import Config
app = typer.Typer(
name="nanobot",
@@ -295,21 +296,33 @@ This file stores important information that should persist across sessions.
console.print(" [dim]Created memory/MEMORY.md[/dim]")
-def _make_provider(config):
+def _make_provider(config: Config):
"""Create LiteLLMProvider from config. Exits if no API key found."""
from nanobot.providers.litellm_provider import LiteLLMProvider
- p = config.get_provider()
+ from nanobot.providers.openai_codex_provider import OpenAICodexProvider
+
model = config.agents.defaults.model
- if not (p and p.api_key) and not model.startswith("bedrock/"):
+ provider_name = config.get_provider_name(model)
+ p = config.get_provider(model)
+
+ # OpenAI Codex (OAuth): don't route via LiteLLM; use the dedicated implementation.
+ if provider_name == "openai_codex" or model.startswith("openai-codex/"):
+ return OpenAICodexProvider(
+ default_model=model,
+ api_base=p.api_base if p else None,
+ )
+
+ if not model.startswith("bedrock/") and not (p and p.api_key):
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(),
+ api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
- provider_name=config.get_provider_name(),
+ provider_name=provider_name,
)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 9d76c2a..0ad77af 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -54,6 +54,9 @@ class LiteLLMProvider(LLMProvider):
spec = self._gateway or find_by_model(model)
if not spec:
return
+ if not spec.env_key:
+ # OAuth/provider-only specs (for example: openai_codex)
+ return
# Gateway/local overrides existing env; standard provider doesn't
if self._gateway:
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index 9c98db5..f6d56aa 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -77,7 +77,6 @@ class OpenAICodexProvider(LLMProvider):
def get_default_model(self) -> str:
return self.default_model
-
def _strip_model_prefix(model: str) -> str:
if model.startswith("openai-codex/"):
return model.split("/", 1)[1]
@@ -95,7 +94,6 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]:
"content-type": "application/json",
}
-
async def _request_codex(
url: str,
headers: dict[str, str],
@@ -109,7 +107,6 @@ async def _request_codex(
raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore")))
return await _consume_sse(response)
-
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
# Nanobot tool definitions already use the OpenAI function schema.
converted: list[dict[str, Any]] = []
@@ -140,7 +137,6 @@ def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
)
return converted
-
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
system_prompt = ""
input_items: list[dict[str, Any]] = []
@@ -200,7 +196,6 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
return system_prompt, input_items
-
def _convert_user_message(content: Any) -> dict[str, Any]:
if isinstance(content, str):
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
@@ -234,12 +229,10 @@ def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
return tool_call_id, None
return "call_0", None
-
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
-
async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
buffer: list[str] = []
async for line in response.aiter_lines():
@@ -259,9 +252,6 @@ async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any],
continue
buffer.append(line)
-
-
-
async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
content = ""
tool_calls: list[ToolCallRequest] = []
@@ -318,7 +308,6 @@ async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequ
return content, tool_calls, finish_reason
-
def _map_finish_reason(status: str | None) -> str:
if not status:
return "stop"
@@ -330,7 +319,6 @@ def _map_finish_reason(status: str | None) -> str:
return "error"
return "stop"
-
def _friendly_error(status_code: int, raw: str) -> str:
if status_code == 429:
return "ChatGPT usage quota exceeded or rate limit triggered. Please try again later."
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..3eaeaba
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,2385 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+ "python_full_version >= '3.14'",
+ "python_full_version < '3.14'",
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/aiohappyeyeballs/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/aiohappyeyeballs/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.13.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767" },
+ { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/aiosignal/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/aiosignal/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" },
+]
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/annotated-doc/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/annotated-doc/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/annotated-types/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/annotated-types/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.12.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/anyio/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/anyio/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c" },
+]
+
+[[package]]
+name = "apscheduler"
+version = "3.11.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "tzlocal" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/apscheduler/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/apscheduler/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d" },
+]
+
+[[package]]
+name = "attrs"
+version = "25.4.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/attrs/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/attrs/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.1.4"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/certifi/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/certifi/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c" },
+]
+
+[[package]]
+name = "chardet"
+version = "5.2.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/chardet/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/chardet/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.4"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2" },
+ { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f" },
+]
+
+[[package]]
+name = "click"
+version = "8.3.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/click/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/click/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/colorama/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/colorama/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" },
+]
+
+[[package]]
+name = "croniter"
+version = "6.0.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "python-dateutil" },
+ { name = "pytz" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/croniter/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/croniter/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368" },
+]
+
+[[package]]
+name = "cssselect"
+version = "1.4.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/cssselect/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/cssselect/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8" },
+]
+
+[[package]]
+name = "dingtalk-stream"
+version = "0.24.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "requests" },
+ { name = "websockets" },
+]
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/dingtalk-stream/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad" },
+]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/distro/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/distro/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" },
+]
+
+[[package]]
+name = "fastuuid"
+version = "0.14.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06" },
+ { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.21.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/filelock/filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/filelock/filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79" },
+ { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2026.2.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/fsspec/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/fsspec/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/h11/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/h11/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" },
+]
+
+[[package]]
+name = "hf-xet"
+version = "1.2.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865" },
+ { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/httpcore/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/httpcore/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/httpx/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/httpx/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" },
+]
+
+[package.optional-dependencies]
+socks = [
+ { name = "socksio" },
+]
+
+[[package]]
+name = "huggingface-hub"
+version = "1.4.1"
+source = { registry = "https://bytedpypi.byted.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://bytedpypi.byted.org/packages/huggingface-hub/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/huggingface-hub/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18" },
+]
+
+[[package]]
+name = "idna"
+version = "3.11"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/idna/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/idna/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" },
+]
+
+[[package]]
+name = "importlib-metadata"
+version = "8.7.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "zipp" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/importlib-metadata/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/importlib-metadata/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/iniconfig/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/iniconfig/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/jinja2/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/jinja2/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" },
+]
+
+[[package]]
+name = "jiter"
+version = "0.13.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59" },
+ { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19" },
+]
+
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/jsonschema/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/jsonschema/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/jsonschema-specifications/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/jsonschema-specifications/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" },
+]
+
+[[package]]
+name = "lark-oapi"
+version = "1.5.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "httpx" },
+ { name = "pycryptodome" },
+ { name = "requests" },
+ { name = "requests-toolbelt" },
+ { name = "websockets" },
+]
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/lark-oapi/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36" },
+]
+
+[[package]]
+name = "litellm"
+version = "1.81.11"
+source = { registry = "https://bytedpypi.byted.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://bytedpypi.byted.org/packages/litellm/litellm-1.81.11.tar.gz", hash = "sha256:fc55aceafda325bd5f704ada61c8be5bd322e6dfa5f8bdcab3290b8732c5857e" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/litellm/litellm-1.81.11-py3-none-any.whl", hash = "sha256:06a66c24742e082ddd2813c87f40f5c12fe7baa73ce1f9457eaf453dc44a0f65" },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/loguru/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/loguru/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c" },
+]
+
+[[package]]
+name = "lxml"
+version = "6.0.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a" },
+ { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e" },
+]
+
+[package.optional-dependencies]
+html-clean = [
+ { name = "lxml-html-clean" },
+]
+
+[[package]]
+name = "lxml-html-clean"
+version = "0.4.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "lxml" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/lxml-html-clean/lxml_html_clean-0.4.3.tar.gz", hash = "sha256:c9df91925b00f836c807beab127aac82575110eacff54d0a75187914f1bd9d8c" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/lxml-html-clean/lxml_html_clean-0.4.3-py3-none-any.whl", hash = "sha256:63fd7b0b9c3a2e4176611c2ca5d61c4c07ffca2de76c14059a81a2825833731e" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/markdown-it-py/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/markdown-it-py/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9" },
+ { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/mdurl/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/mdurl/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2" },
+ { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" },
+]
+
+[[package]]
+name = "nanobot-ai"
+version = "0.1.3.post5"
+source = { editable = "." }
+dependencies = [
+ { name = "croniter" },
+ { name = "dingtalk-stream" },
+ { name = "httpx" },
+ { name = "lark-oapi" },
+ { name = "litellm" },
+ { name = "loguru" },
+ { name = "oauth-cli-kit" },
+ { name = "pydantic" },
+ { name = "pydantic-settings" },
+ { name = "python-telegram-bot", extra = ["socks"] },
+ { name = "qq-botpy" },
+ { name = "readability-lxml" },
+ { name = "rich" },
+ { name = "slack-sdk" },
+ { name = "socksio" },
+ { name = "typer" },
+ { name = "websocket-client" },
+ { name = "websockets" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "croniter", specifier = ">=2.0.0" },
+ { name = "dingtalk-stream", specifier = ">=0.4.0" },
+ { name = "httpx", specifier = ">=0.25.0" },
+ { name = "lark-oapi", specifier = ">=1.0.0" },
+ { name = "litellm", specifier = ">=1.0.0" },
+ { name = "loguru", specifier = ">=0.7.0" },
+ { name = "oauth-cli-kit", specifier = ">=0.1.1" },
+ { name = "pydantic", specifier = ">=2.0.0" },
+ { name = "pydantic-settings", specifier = ">=2.0.0" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
+ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
+ { name = "python-telegram-bot", extras = ["socks"], specifier = ">=21.0" },
+ { name = "qq-botpy", specifier = ">=1.0.0" },
+ { name = "readability-lxml", specifier = ">=0.8.0" },
+ { name = "rich", specifier = ">=13.0.0" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
+ { name = "slack-sdk", specifier = ">=3.26.0" },
+ { name = "socksio", specifier = ">=1.0.0" },
+ { name = "typer", specifier = ">=0.9.0" },
+ { name = "websocket-client", specifier = ">=1.6.0" },
+ { name = "websockets", specifier = ">=12.0" },
+]
+provides-extras = ["dev"]
+
+[[package]]
+name = "oauth-cli-kit"
+version = "0.1.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "httpx" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/oauth-cli-kit/oauth_cli_kit-0.1.1.tar.gz", hash = "sha256:6a13f9b9ca738115e46314fff87506887603a21888527d1c0c9e420bc9401925" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/oauth-cli-kit/oauth_cli_kit-0.1.1-py3-none-any.whl", hash = "sha256:4b74633847c24ae677cf404bddd491b92e4ca99b1f7cb6bc5d67a3167e9b9910" },
+]
+
+[[package]]
+name = "openai"
+version = "2.20.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/openai/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/openai/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/packaging/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/packaging/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.7.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/platformdirs/platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/platformdirs/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/pluggy/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pluggy/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.4.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9" },
+ { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237" },
+]
+
+[[package]]
+name = "pycryptodome"
+version = "3.23.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2" },
+ { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.12.5"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/pydantic/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pydantic/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.41.5"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f" },
+ { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.12.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/pydantic-settings/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pydantic-settings/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/pygments/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pygments/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/pytest/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pytest/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/pytest-asyncio/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pytest-asyncio/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/python-dateutil/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/python-dateutil/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/python-dotenv/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/python-dotenv/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" },
+]
+
+[[package]]
+name = "python-telegram-bot"
+version = "22.6"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "httpcore", marker = "python_full_version >= '3.14'" },
+ { name = "httpx" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/python-telegram-bot/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/python-telegram-bot/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0" },
+]
+
+[package.optional-dependencies]
+socks = [
+ { name = "httpx", extra = ["socks"] },
+]
+
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/pytz/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pytz/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" },
+ { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" },
+]
+
+[[package]]
+name = "qq-botpy"
+version = "1.2.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "apscheduler" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/qq-botpy/qq-botpy-1.2.1.tar.gz", hash = "sha256:442172a0557a9b43d2777d1c5e072090a9d1a54d588d1c5da8d3efc014f4887f" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/qq-botpy/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01" },
+]
+
+[[package]]
+name = "readability-lxml"
+version = "0.8.4.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "chardet" },
+ { name = "cssselect" },
+ { name = "lxml", extra = ["html-clean"] },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/readability-lxml/readability_lxml-0.8.4.1.tar.gz", hash = "sha256:9d2924f5942dd7f37fb4da353263b22a3e877ccf922d0e45e348e4177b035a53" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/readability-lxml/readability_lxml-0.8.4.1-py3-none-any.whl", hash = "sha256:874c0cea22c3bf2b78c7f8df831bfaad3c0a89b7301d45a188db581652b4b465" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/referencing/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/referencing/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" },
+]
+
+[[package]]
+name = "regex"
+version = "2026.1.15"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf" },
+ { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70" },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/requests/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/requests/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" },
+]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/requests-toolbelt/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/requests-toolbelt/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" },
+]
+
+[[package]]
+name = "rich"
+version = "14.3.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/rich/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/rich/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69" },
+]
+
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4" },
+ { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336" },
+ { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/shellingham/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/shellingham/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/six/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/six/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" },
+]
+
+[[package]]
+name = "slack-sdk"
+version = "3.40.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/slack-sdk/slack_sdk-3.40.0.tar.gz", hash = "sha256:87b9a79d1d6e19a2b1877727a0ec6f016d82d30a6a410389fba87c221c99f10e" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/slack-sdk/slack_sdk-3.40.0-py2.py3-none-any.whl", hash = "sha256:f2bada5ed3adb10a01e154e90db01d6d8938d0461b5790c12bcb807b2d28bbe2" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/sniffio/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/sniffio/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" },
+]
+
+[[package]]
+name = "socksio"
+version = "1.0.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/socksio/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/socksio/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3" },
+]
+
+[[package]]
+name = "tiktoken"
+version = "0.12.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "regex" },
+ { name = "requests" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0" },
+ { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71" },
+]
+
+[[package]]
+name = "tokenizers"
+version = "0.22.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "huggingface-hub" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48" },
+ { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/tqdm/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/tqdm/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" },
+]
+
+[[package]]
+name = "typer"
+version = "0.23.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "click" },
+ { name = "rich" },
+ { name = "shellingham" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/typer/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/typer/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913" },
+]
+
+[[package]]
+name = "typer-slim"
+version = "0.23.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "typer" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/typer-slim/typer_slim-0.23.0.tar.gz", hash = "sha256:be8b60243df27cfee444c6db1b10a85f4f3e54d940574f31a996f78aa35a8254" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/typer-slim/typer_slim-0.23.0-py3-none-any.whl", hash = "sha256:1d693daf22d998a7b1edab8413cdcb8af07254154ce3956c1664dc11b01e2f8b" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/typing-extensions/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/typing-extensions/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/typing-inspection/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/typing-inspection/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2025.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/tzdata/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/tzdata/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1" },
+]
+
+[[package]]
+name = "tzlocal"
+version = "5.3.1"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/tzlocal/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/tzlocal/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/urllib3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/urllib3/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" },
+]
+
+[[package]]
+name = "websocket-client"
+version = "1.9.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/websocket-client/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/websocket-client/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef" },
+]
+
+[[package]]
+name = "websockets"
+version = "16.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767" },
+ { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec" },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/win32-setctime/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/win32-setctime/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.22.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1" },
+ { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff" },
+]
+
+[[package]]
+name = "zipp"
+version = "3.23.0"
+source = { registry = "https://bytedpypi.byted.org/simple/" }
+sdist = { url = "https://bytedpypi.byted.org/packages/zipp/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" }
+wheels = [
+ { url = "https://bytedpypi.byted.org/packages/zipp/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e" },
+]
From 442136a313acd37930296b954e205d44272bc267 Mon Sep 17 00:00:00 2001
From: qiupinhua
Date: Fri, 13 Feb 2026 18:52:43 +0800
Subject: [PATCH 098/506] fix: remove uv.lock
---
uv.lock | 2385 -------------------------------------------------------
1 file changed, 2385 deletions(-)
delete mode 100644 uv.lock
diff --git a/uv.lock b/uv.lock
deleted file mode 100644
index 3eaeaba..0000000
--- a/uv.lock
+++ /dev/null
@@ -1,2385 +0,0 @@
-version = 1
-revision = 3
-requires-python = ">=3.11"
-resolution-markers = [
- "python_full_version >= '3.14'",
- "python_full_version < '3.14'",
-]
-
-[[package]]
-name = "aiohappyeyeballs"
-version = "2.6.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/aiohappyeyeballs/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/aiohappyeyeballs/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8" },
-]
-
-[[package]]
-name = "aiohttp"
-version = "3.13.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "aiohappyeyeballs" },
- { name = "aiosignal" },
- { name = "attrs" },
- { name = "frozenlist" },
- { name = "multidict" },
- { name = "propcache" },
- { name = "yarl" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767" },
- { url = "https://bytedpypi.byted.org/packages/aiohttp/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344" },
-]
-
-[[package]]
-name = "aiosignal"
-version = "1.4.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "frozenlist" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/aiosignal/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/aiosignal/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e" },
-]
-
-[[package]]
-name = "annotated-doc"
-version = "0.0.4"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/annotated-doc/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/annotated-doc/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320" },
-]
-
-[[package]]
-name = "annotated-types"
-version = "0.7.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/annotated-types/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/annotated-types/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53" },
-]
-
-[[package]]
-name = "anyio"
-version = "4.12.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "idna" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/anyio/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/anyio/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c" },
-]
-
-[[package]]
-name = "apscheduler"
-version = "3.11.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "tzlocal" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/apscheduler/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/apscheduler/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d" },
-]
-
-[[package]]
-name = "attrs"
-version = "25.4.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/attrs/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/attrs/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373" },
-]
-
-[[package]]
-name = "certifi"
-version = "2026.1.4"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/certifi/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/certifi/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c" },
-]
-
-[[package]]
-name = "chardet"
-version = "5.2.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/chardet/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/chardet/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" },
-]
-
-[[package]]
-name = "charset-normalizer"
-version = "3.4.4"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2" },
- { url = "https://bytedpypi.byted.org/packages/charset-normalizer/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f" },
-]
-
-[[package]]
-name = "click"
-version = "8.3.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/click/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/click/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6" },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/colorama/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/colorama/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" },
-]
-
-[[package]]
-name = "croniter"
-version = "6.0.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "python-dateutil" },
- { name = "pytz" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/croniter/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/croniter/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368" },
-]
-
-[[package]]
-name = "cssselect"
-version = "1.4.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/cssselect/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/cssselect/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8" },
-]
-
-[[package]]
-name = "dingtalk-stream"
-version = "0.24.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "aiohttp" },
- { name = "requests" },
- { name = "websockets" },
-]
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/dingtalk-stream/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad" },
-]
-
-[[package]]
-name = "distro"
-version = "1.9.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/distro/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/distro/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" },
-]
-
-[[package]]
-name = "fastuuid"
-version = "0.14.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0.tar.gz", hash = "sha256:178947fc2f995b38497a74172adee64fdeb8b7ec18f2a5934d037641ba265d26" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:73946cb950c8caf65127d4e9a325e2b6be0442a224fd51ba3b6ac44e1912ce34" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:12ac85024637586a5b69645e7ed986f7535106ed3013640a393a03e461740cb7" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:05a8dde1f395e0c9b4be515b7a521403d1e8349443e7641761af07c7ad1624b1" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09378a05020e3e4883dfdab438926f31fea15fd17604908f3d39cbeb22a0b4dc" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbb0c4b15d66b435d2538f3827f05e44e2baafcc003dd7d8472dc67807ab8fd8" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cd5a7f648d4365b41dbf0e38fe8da4884e57bed4e77c83598e076ac0c93995e7" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c0a94245afae4d7af8c43b3159d5e3934c53f47140be0be624b96acd672ceb73" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:2b29e23c97e77c3a9514d70ce343571e469098ac7f5a269320a0f0b3e193ab36" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1e690d48f923c253f28151b3a6b4e335f2b06bf669c68a02665bc150b7839e94" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-win32.whl", hash = "sha256:a6f46790d59ab38c6aa0e35c681c0484b50dc0acf9e2679c005d61e019313c24" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e150eab56c95dc9e3fefc234a0eedb342fac433dacc273cd4d150a5b0871e1fa" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77e94728324b63660ebf8adb27055e92d2e4611645bf12ed9d88d30486471d0a" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:caa1f14d2102cb8d353096bc6ef6c13b2c81f347e6ab9d6fbd48b9dea41c153d" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d23ef06f9e67163be38cece704170486715b177f6baae338110983f99a72c070" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c9ec605ace243b6dbe3bd27ebdd5d33b00d8d1d3f580b39fdd15cd96fd71796" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb3c0d7fef6674bbeacdd6dbd386924a7b60b26de849266d1ff6602937675c8" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab3f5d36e4393e628a4df337c2c039069344db5f4b9d2a3c9cea48284f1dd741" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b9a0ca4f03b7e0b01425281ffd44e99d360e15c895f1907ca105854ed85e2057" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3acdf655684cc09e60fb7e4cf524e8f42ea760031945aa8086c7eae2eeeabeb8" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-win32.whl", hash = "sha256:9579618be6280700ae36ac42c3efd157049fe4dd40ca49b021280481c78c3176" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:d9e4332dc4ba054434a9594cbfaf7823b57993d7d8e7267831c3e059857cf397" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:77a09cb7427e7af74c594e409f7731a0cf887221de2f698e1ca0ebf0f3139021" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9bd57289daf7b153bfa3e8013446aa144ce5e8c825e9e366d455155ede5ea2dc" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac60fc860cdf3c3f327374db87ab8e064c86566ca8c49d2e30df15eda1b0c2d5" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab32f74bd56565b186f036e33129da77db8be09178cd2f5206a5d4035fb2a23f" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e678459cf4addaedd9936bbb038e35b3f6b2061330fd8f2f6a1d80414c0f87" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e3cc56742f76cd25ecb98e4b82a25f978ccffba02e4bdce8aba857b6d85d87b" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cb9a030f609194b679e1660f7e32733b7a0f332d519c5d5a6a0a580991290022" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:09098762aad4f8da3a888eb9ae01c84430c907a297b97166b8abc07b640f2995" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1383fff584fa249b16329a059c68ad45d030d5a4b70fb7c73a08d98fd53bcdab" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a0809f8cc5731c066c909047f9a314d5f536c871a7a22e815cc4967c110ac9ad" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df14e92e7ad3276327631c9e7cec09e32572ce82089c55cb1bb8df71cf394ed" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b852a870a61cfc26c884af205d502881a2e59cc07076b60ab4a951cc0c94d1ad" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c7502d6f54cd08024c3ea9b3514e2d6f190feb2f46e6dbcd3747882264bb5f7b" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ca61b592120cf314cfd66e662a5b54a578c5a15b26305e1b8b618a6f22df714" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa75b6657ec129d0abded3bec745e6f7ab642e6dba3a5272a68247e85f5f316f" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8a0dfea3972200f72d4c7df02c8ac70bad1bb4c58d7e0ec1e6f341679073a7f" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1bf539a7a95f35b419f9ad105d5a8a35036df35fdafae48fb2fd2e5f318f0d75" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9a133bf9cc78fdbd1179cb58a59ad0100aa32d8675508150f3658814aeefeaa4" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:f54d5b36c56a2d5e1a31e73b950b28a0d83eb0c37b91d10408875a5a29494bad" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ec27778c6ca3393ef662e2762dba8af13f4ec1aaa32d08d77f71f2a70ae9feb8" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-win32.whl", hash = "sha256:e23fc6a83f112de4be0cc1990e5b127c27663ae43f866353166f87df58e73d06" },
- { url = "https://bytedpypi.byted.org/packages/fastuuid/fastuuid-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:df61342889d0f5e7a32f7284e55ef95103f2110fee433c2ae7c2c0956d76ac8a" },
-]
-
-[[package]]
-name = "filelock"
-version = "3.21.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/filelock/filelock-3.21.2.tar.gz", hash = "sha256:cfd218cfccf8b947fce7837da312ec3359d10ef2a47c8602edd59e0bacffb708" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/filelock/filelock-3.21.2-py3-none-any.whl", hash = "sha256:d6cd4dbef3e1bb63bc16500fc5aa100f16e405bbff3fb4231711851be50c1560" },
-]
-
-[[package]]
-name = "frozenlist"
-version = "1.8.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79" },
- { url = "https://bytedpypi.byted.org/packages/frozenlist/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d" },
-]
-
-[[package]]
-name = "fsspec"
-version = "2026.2.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/fsspec/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/fsspec/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437" },
-]
-
-[[package]]
-name = "h11"
-version = "0.16.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/h11/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/h11/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86" },
-]
-
-[[package]]
-name = "hf-xet"
-version = "1.2.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0.tar.gz", hash = "sha256:a8c27070ca547293b6890c4bf389f713f80e8c478631432962bb7f4bc0bd7d7f" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:ceeefcd1b7aed4956ae8499e2199607765fbd1c60510752003b6cc0b8413b649" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b70218dd548e9840224df5638fdc94bd033552963cfa97f9170829381179c813" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d40b18769bb9a8bc82a9ede575ce1a44c75eb80e7375a01d76259089529b5dc" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd3a6027d59cfb60177c12d6424e31f4b5ff13d8e3a1247b3a584bf8977e6df5" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6de1fc44f58f6dd937956c8d304d8c2dea264c80680bcfa61ca4a15e7b76780f" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f182f264ed2acd566c514e45da9f2119110e48a87a327ca271027904c70c5832" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:293a7a3787e5c95d7be1857358a9130694a9c6021de3f27fa233f37267174382" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:10bfab528b968c70e062607f663e21e34e2bba349e8038db546646875495179e" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a212e842647b02eb6a911187dc878e79c4aa0aa397e88dd3b26761676e8c1f8" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30e06daccb3a7d4c065f34fc26c14c74f4653069bb2b194e7f18f17cbe9939c0" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:29c8fc913a529ec0a91867ce3d119ac1aac966e098cf49501800c870328cc090" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e159cbfcfbb29f920db2c09ed8b660eb894640d284f102ada929b6e3dc410a" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c91d5ae931510107f148874e9e2de8a16052b6f1b3ca3c1b12f15ccb491390f" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:210d577732b519ac6ede149d2f2f34049d44e8622bf14eb3d63bbcd2d4b332dc" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:46740d4ac024a7ca9b22bebf77460ff43332868b661186a8e46c227fdae01848" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:27df617a076420d8845bea087f59303da8be17ed7ec0cd7ee3b9b9f579dff0e4" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d06fa97c8562fb3ee7a378dd9b51e343bc5bc8190254202c9771029152f5e08c" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c1428c9ae73ec0939410ec73023c4f842927f39db09b063b9482dac5a3bb737" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a55558084c16b09b5ed32ab9ed38421e2d87cf3f1f89815764d1177081b99865" },
- { url = "https://bytedpypi.byted.org/packages/hf-xet/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69" },
-]
-
-[[package]]
-name = "httpcore"
-version = "1.0.9"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "certifi" },
- { name = "h11" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/httpcore/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/httpcore/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" },
-]
-
-[[package]]
-name = "httpx"
-version = "0.28.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
- { name = "idna" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/httpx/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/httpx/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" },
-]
-
-[package.optional-dependencies]
-socks = [
- { name = "socksio" },
-]
-
-[[package]]
-name = "huggingface-hub"
-version = "1.4.1"
-source = { registry = "https://bytedpypi.byted.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://bytedpypi.byted.org/packages/huggingface-hub/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/huggingface-hub/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18" },
-]
-
-[[package]]
-name = "idna"
-version = "3.11"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/idna/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/idna/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea" },
-]
-
-[[package]]
-name = "importlib-metadata"
-version = "8.7.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "zipp" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/importlib-metadata/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/importlib-metadata/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151" },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.3.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/iniconfig/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/iniconfig/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" },
-]
-
-[[package]]
-name = "jinja2"
-version = "3.1.6"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/jinja2/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/jinja2/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" },
-]
-
-[[package]]
-name = "jiter"
-version = "0.13.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59" },
- { url = "https://bytedpypi.byted.org/packages/jiter/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19" },
-]
-
-[[package]]
-name = "jsonschema"
-version = "4.26.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "attrs" },
- { name = "jsonschema-specifications" },
- { name = "referencing" },
- { name = "rpds-py" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/jsonschema/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/jsonschema/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce" },
-]
-
-[[package]]
-name = "jsonschema-specifications"
-version = "2025.9.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "referencing" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/jsonschema-specifications/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/jsonschema-specifications/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe" },
-]
-
-[[package]]
-name = "lark-oapi"
-version = "1.5.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "httpx" },
- { name = "pycryptodome" },
- { name = "requests" },
- { name = "requests-toolbelt" },
- { name = "websockets" },
-]
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/lark-oapi/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36" },
-]
-
-[[package]]
-name = "litellm"
-version = "1.81.11"
-source = { registry = "https://bytedpypi.byted.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://bytedpypi.byted.org/packages/litellm/litellm-1.81.11.tar.gz", hash = "sha256:fc55aceafda325bd5f704ada61c8be5bd322e6dfa5f8bdcab3290b8732c5857e" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/litellm/litellm-1.81.11-py3-none-any.whl", hash = "sha256:06a66c24742e082ddd2813c87f40f5c12fe7baa73ce1f9457eaf453dc44a0f65" },
-]
-
-[[package]]
-name = "loguru"
-version = "0.7.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "win32-setctime", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/loguru/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/loguru/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c" },
-]
-
-[[package]]
-name = "lxml"
-version = "6.0.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a" },
- { url = "https://bytedpypi.byted.org/packages/lxml/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e" },
-]
-
-[package.optional-dependencies]
-html-clean = [
- { name = "lxml-html-clean" },
-]
-
-[[package]]
-name = "lxml-html-clean"
-version = "0.4.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "lxml" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/lxml-html-clean/lxml_html_clean-0.4.3.tar.gz", hash = "sha256:c9df91925b00f836c807beab127aac82575110eacff54d0a75187914f1bd9d8c" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/lxml-html-clean/lxml_html_clean-0.4.3-py3-none-any.whl", hash = "sha256:63fd7b0b9c3a2e4176611c2ca5d61c4c07ffca2de76c14059a81a2825833731e" },
-]
-
-[[package]]
-name = "markdown-it-py"
-version = "4.0.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "mdurl" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/markdown-it-py/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/markdown-it-py/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147" },
-]
-
-[[package]]
-name = "markupsafe"
-version = "3.0.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9" },
- { url = "https://bytedpypi.byted.org/packages/markupsafe/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa" },
-]
-
-[[package]]
-name = "mdurl"
-version = "0.1.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/mdurl/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/mdurl/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" },
-]
-
-[[package]]
-name = "multidict"
-version = "6.7.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2" },
- { url = "https://bytedpypi.byted.org/packages/multidict/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56" },
-]
-
-[[package]]
-name = "nanobot-ai"
-version = "0.1.3.post5"
-source = { editable = "." }
-dependencies = [
- { name = "croniter" },
- { name = "dingtalk-stream" },
- { name = "httpx" },
- { name = "lark-oapi" },
- { name = "litellm" },
- { name = "loguru" },
- { name = "oauth-cli-kit" },
- { name = "pydantic" },
- { name = "pydantic-settings" },
- { name = "python-telegram-bot", extra = ["socks"] },
- { name = "qq-botpy" },
- { name = "readability-lxml" },
- { name = "rich" },
- { name = "slack-sdk" },
- { name = "socksio" },
- { name = "typer" },
- { name = "websocket-client" },
- { name = "websockets" },
-]
-
-[package.optional-dependencies]
-dev = [
- { name = "pytest" },
- { name = "pytest-asyncio" },
- { name = "ruff" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "croniter", specifier = ">=2.0.0" },
- { name = "dingtalk-stream", specifier = ">=0.4.0" },
- { name = "httpx", specifier = ">=0.25.0" },
- { name = "lark-oapi", specifier = ">=1.0.0" },
- { name = "litellm", specifier = ">=1.0.0" },
- { name = "loguru", specifier = ">=0.7.0" },
- { name = "oauth-cli-kit", specifier = ">=0.1.1" },
- { name = "pydantic", specifier = ">=2.0.0" },
- { name = "pydantic-settings", specifier = ">=2.0.0" },
- { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
- { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
- { name = "python-telegram-bot", extras = ["socks"], specifier = ">=21.0" },
- { name = "qq-botpy", specifier = ">=1.0.0" },
- { name = "readability-lxml", specifier = ">=0.8.0" },
- { name = "rich", specifier = ">=13.0.0" },
- { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
- { name = "slack-sdk", specifier = ">=3.26.0" },
- { name = "socksio", specifier = ">=1.0.0" },
- { name = "typer", specifier = ">=0.9.0" },
- { name = "websocket-client", specifier = ">=1.6.0" },
- { name = "websockets", specifier = ">=12.0" },
-]
-provides-extras = ["dev"]
-
-[[package]]
-name = "oauth-cli-kit"
-version = "0.1.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "httpx" },
- { name = "platformdirs" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/oauth-cli-kit/oauth_cli_kit-0.1.1.tar.gz", hash = "sha256:6a13f9b9ca738115e46314fff87506887603a21888527d1c0c9e420bc9401925" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/oauth-cli-kit/oauth_cli_kit-0.1.1-py3-none-any.whl", hash = "sha256:4b74633847c24ae677cf404bddd491b92e4ca99b1f7cb6bc5d67a3167e9b9910" },
-]
-
-[[package]]
-name = "openai"
-version = "2.20.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "anyio" },
- { name = "distro" },
- { name = "httpx" },
- { name = "jiter" },
- { name = "pydantic" },
- { name = "sniffio" },
- { name = "tqdm" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/openai/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/openai/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99" },
-]
-
-[[package]]
-name = "packaging"
-version = "26.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/packaging/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/packaging/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529" },
-]
-
-[[package]]
-name = "platformdirs"
-version = "4.7.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/platformdirs/platformdirs-4.7.0.tar.gz", hash = "sha256:fd1a5f8599c85d49b9ac7d6e450bc2f1aaf4a23f1fe86d09952fe20ad365cf36" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/platformdirs/platformdirs-4.7.0-py3-none-any.whl", hash = "sha256:1ed8db354e344c5bb6039cd727f096af975194b508e37177719d562b2b540ee6" },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/pluggy/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pluggy/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" },
-]
-
-[[package]]
-name = "propcache"
-version = "0.4.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9" },
- { url = "https://bytedpypi.byted.org/packages/propcache/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237" },
-]
-
-[[package]]
-name = "pycryptodome"
-version = "3.23.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2" },
- { url = "https://bytedpypi.byted.org/packages/pycryptodome/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c" },
-]
-
-[[package]]
-name = "pydantic"
-version = "2.12.5"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "annotated-types" },
- { name = "pydantic-core" },
- { name = "typing-extensions" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/pydantic/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pydantic/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d" },
-]
-
-[[package]]
-name = "pydantic-core"
-version = "2.41.5"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f" },
- { url = "https://bytedpypi.byted.org/packages/pydantic-core/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51" },
-]
-
-[[package]]
-name = "pydantic-settings"
-version = "2.12.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "pydantic" },
- { name = "python-dotenv" },
- { name = "typing-inspection" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/pydantic-settings/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pydantic-settings/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809" },
-]
-
-[[package]]
-name = "pygments"
-version = "2.19.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/pygments/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pygments/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b" },
-]
-
-[[package]]
-name = "pytest"
-version = "9.0.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/pytest/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pytest/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b" },
-]
-
-[[package]]
-name = "pytest-asyncio"
-version = "1.3.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "pytest" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/pytest-asyncio/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pytest-asyncio/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" },
-]
-
-[[package]]
-name = "python-dateutil"
-version = "2.9.0.post0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "six" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/python-dateutil/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/python-dateutil/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" },
-]
-
-[[package]]
-name = "python-dotenv"
-version = "1.2.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/python-dotenv/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/python-dotenv/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61" },
-]
-
-[[package]]
-name = "python-telegram-bot"
-version = "22.6"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "httpcore", marker = "python_full_version >= '3.14'" },
- { name = "httpx" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/python-telegram-bot/python_telegram_bot-22.6.tar.gz", hash = "sha256:50ae8cc10f8dff01445628687951020721f37956966b92a91df4c1bf2d113742" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/python-telegram-bot/python_telegram_bot-22.6-py3-none-any.whl", hash = "sha256:e598fe171c3dde2dfd0f001619ee9110eece66761a677b34719fb18934935ce0" },
-]
-
-[package.optional-dependencies]
-socks = [
- { name = "httpx", extra = ["socks"] },
-]
-
-[[package]]
-name = "pytz"
-version = "2025.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/pytz/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pytz/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00" },
-]
-
-[[package]]
-name = "pyyaml"
-version = "6.0.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9" },
- { url = "https://bytedpypi.byted.org/packages/pyyaml/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" },
-]
-
-[[package]]
-name = "qq-botpy"
-version = "1.2.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "aiohttp" },
- { name = "apscheduler" },
- { name = "pyyaml" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/qq-botpy/qq-botpy-1.2.1.tar.gz", hash = "sha256:442172a0557a9b43d2777d1c5e072090a9d1a54d588d1c5da8d3efc014f4887f" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/qq-botpy/qq_botpy-1.2.1-py3-none-any.whl", hash = "sha256:18b215690dfed88f711322136ec54b6760040b9b1608eb5db7a44e00f59e4f01" },
-]
-
-[[package]]
-name = "readability-lxml"
-version = "0.8.4.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "chardet" },
- { name = "cssselect" },
- { name = "lxml", extra = ["html-clean"] },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/readability-lxml/readability_lxml-0.8.4.1.tar.gz", hash = "sha256:9d2924f5942dd7f37fb4da353263b22a3e877ccf922d0e45e348e4177b035a53" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/readability-lxml/readability_lxml-0.8.4.1-py3-none-any.whl", hash = "sha256:874c0cea22c3bf2b78c7f8df831bfaad3c0a89b7301d45a188db581652b4b465" },
-]
-
-[[package]]
-name = "referencing"
-version = "0.37.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "attrs" },
- { name = "rpds-py" },
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/referencing/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/referencing/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231" },
-]
-
-[[package]]
-name = "regex"
-version = "2026.1.15"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf" },
- { url = "https://bytedpypi.byted.org/packages/regex/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70" },
-]
-
-[[package]]
-name = "requests"
-version = "2.32.5"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/requests/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/requests/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" },
-]
-
-[[package]]
-name = "requests-toolbelt"
-version = "1.0.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "requests" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/requests-toolbelt/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/requests-toolbelt/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" },
-]
-
-[[package]]
-name = "rich"
-version = "14.3.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "markdown-it-py" },
- { name = "pygments" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/rich/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/rich/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69" },
-]
-
-[[package]]
-name = "rpds-py"
-version = "0.30.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4" },
- { url = "https://bytedpypi.byted.org/packages/rpds-py/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e" },
-]
-
-[[package]]
-name = "ruff"
-version = "0.15.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336" },
- { url = "https://bytedpypi.byted.org/packages/ruff/ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416" },
-]
-
-[[package]]
-name = "shellingham"
-version = "1.5.4"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/shellingham/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/shellingham/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686" },
-]
-
-[[package]]
-name = "six"
-version = "1.17.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/six/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/six/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274" },
-]
-
-[[package]]
-name = "slack-sdk"
-version = "3.40.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/slack-sdk/slack_sdk-3.40.0.tar.gz", hash = "sha256:87b9a79d1d6e19a2b1877727a0ec6f016d82d30a6a410389fba87c221c99f10e" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/slack-sdk/slack_sdk-3.40.0-py2.py3-none-any.whl", hash = "sha256:f2bada5ed3adb10a01e154e90db01d6d8938d0461b5790c12bcb807b2d28bbe2" },
-]
-
-[[package]]
-name = "sniffio"
-version = "1.3.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/sniffio/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/sniffio/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2" },
-]
-
-[[package]]
-name = "socksio"
-version = "1.0.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/socksio/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/socksio/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3" },
-]
-
-[[package]]
-name = "tiktoken"
-version = "0.12.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "regex" },
- { name = "requests" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e227c7f96925003487c33b1b32265fad2fbcec2b7cf4817afb76d416f40f6bb" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c06cf0fcc24c2cb2adb5e185c7082a82cba29c17575e828518c2f11a01f445aa" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f18f249b041851954217e9fd8e5c00b024ab2315ffda5ed77665a05fa91f42dc" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:47a5bc270b8c3db00bb46ece01ef34ad050e364b51d406b6f9730b64ac28eded" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:508fa71810c0efdcd1b898fda574889ee62852989f7c1667414736bcb2b9a4bd" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1af81a6c44f008cba48494089dd98cccb8b313f55e961a52f5b222d1e507967" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e68e3e593637b53e56f7237be560f7a394451cb8c11079755e80ae64b9e6def" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b97f74aca0d78a1ff21b8cd9e9925714c15a9236d6ceacf5c7327c117e6e21e8" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b90f5ad190a4bb7c3eb30c5fa32e1e182ca1ca79f05e49b448438c3e225a49b" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:65b26c7a780e2139e73acc193e5c63ac754021f160df919add909c1492c0fb37" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:edde1ec917dfd21c1f2f8046b86348b0f54a2c0547f68149d8600859598769ad" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:35a2f8ddd3824608b3d650a000c1ef71f730d0c56486845705a8248da00f9fe5" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83d16643edb7fa2c99eff2ab7733508aae1eebb03d5dfc46f5565862810f24e3" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc5288f34a8bc02e1ea7047b8d041104791d2ddbf42d1e5fa07822cbffe16bd" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:775c2c55de2310cc1bc9a3ad8826761cbdc87770e586fd7b6da7d4589e13dab3" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a01b12f69052fbe4b080a2cfb867c4de12c704b56178edf1d1d7b273561db160" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:01d99484dc93b129cd0964f9d34eee953f2737301f18b3c7257bf368d7615baa" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4a1a4fcd021f022bfc81904a911d3df0f6543b9e7627b51411da75ff2fe7a1be" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:981a81e39812d57031efdc9ec59fa32b2a5a5524d20d4776574c4b4bd2e9014a" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9baf52f84a3f42eef3ff4e754a0db79a13a27921b457ca9832cf944c6be4f8f3" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b8a0cd0c789a61f31bf44851defbd609e8dd1e2c8589c614cc1060940ef1f697" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d5f89ea5680066b68bcb797ae85219c72916c922ef0fcdd3480c7d2315ffff16" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b4e7ed1c6a7a8a60a3230965bdedba8cc58f68926b835e519341413370e0399a" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:fc530a28591a2d74bce821d10b418b26a094bf33839e69042a6e86ddb7a7fb27" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:06a9f4f49884139013b138920a4c393aa6556b2f8f536345f11819389c703ebb" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:04f0e6a985d95913cabc96a741c5ffec525a2c72e9df086ff17ebe35985c800e" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ee8f9ae00c41770b5f9b0bb1235474768884ae157de3beb5439ca0fd70f3e25" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dc2dd125a62cb2b3d858484d6c614d136b5b848976794edfb63688d539b8b93f" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0" },
- { url = "https://bytedpypi.byted.org/packages/tiktoken/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71" },
-]
-
-[[package]]
-name = "tokenizers"
-version = "0.22.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "huggingface-hub" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48" },
- { url = "https://bytedpypi.byted.org/packages/tokenizers/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc" },
-]
-
-[[package]]
-name = "tqdm"
-version = "4.67.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/tqdm/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/tqdm/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" },
-]
-
-[[package]]
-name = "typer"
-version = "0.23.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "annotated-doc" },
- { name = "click" },
- { name = "rich" },
- { name = "shellingham" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/typer/typer-0.23.0.tar.gz", hash = "sha256:d8378833e47ada5d3d093fa20c4c63427cc4e27127f6b349a6c359463087d8cc" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/typer/typer-0.23.0-py3-none-any.whl", hash = "sha256:79f4bc262b6c37872091072a3cb7cb6d7d79ee98c0c658b4364bdcde3c42c913" },
-]
-
-[[package]]
-name = "typer-slim"
-version = "0.23.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "typer" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/typer-slim/typer_slim-0.23.0.tar.gz", hash = "sha256:be8b60243df27cfee444c6db1b10a85f4f3e54d940574f31a996f78aa35a8254" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/typer-slim/typer_slim-0.23.0-py3-none-any.whl", hash = "sha256:1d693daf22d998a7b1edab8413cdcb8af07254154ce3956c1664dc11b01e2f8b" },
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.15.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/typing-extensions/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/typing-extensions/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" },
-]
-
-[[package]]
-name = "typing-inspection"
-version = "0.4.2"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/typing-inspection/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/typing-inspection/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7" },
-]
-
-[[package]]
-name = "tzdata"
-version = "2025.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/tzdata/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/tzdata/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1" },
-]
-
-[[package]]
-name = "tzlocal"
-version = "5.3.1"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "tzdata", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/tzlocal/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/tzlocal/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d" },
-]
-
-[[package]]
-name = "urllib3"
-version = "2.6.3"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/urllib3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/urllib3/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" },
-]
-
-[[package]]
-name = "websocket-client"
-version = "1.9.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/websocket-client/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/websocket-client/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef" },
-]
-
-[[package]]
-name = "websockets"
-version = "16.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767" },
- { url = "https://bytedpypi.byted.org/packages/websockets/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec" },
-]
-
-[[package]]
-name = "win32-setctime"
-version = "1.2.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/win32-setctime/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/win32-setctime/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390" },
-]
-
-[[package]]
-name = "yarl"
-version = "1.22.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-dependencies = [
- { name = "idna" },
- { name = "multidict" },
- { name = "propcache" },
-]
-sdist = { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1" },
- { url = "https://bytedpypi.byted.org/packages/yarl/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff" },
-]
-
-[[package]]
-name = "zipp"
-version = "3.23.0"
-source = { registry = "https://bytedpypi.byted.org/simple/" }
-sdist = { url = "https://bytedpypi.byted.org/packages/zipp/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166" }
-wheels = [
- { url = "https://bytedpypi.byted.org/packages/zipp/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e" },
-]
From 8a11490798e0a60335887129f1ae2f9e87d6a24d Mon Sep 17 00:00:00 2001
From: Luke Milby
Date: Fri, 13 Feb 2026 08:43:49 -0500
Subject: [PATCH 099/506] updated logic for onboard function not ask for to
overwrite workspace since the logic already ensures nothing will be
overwritten. Added onboard command tests and removed tests from gitignore
---
.gitignore | 1 -
nanobot/cli/commands.py | 10 +--
tests/test_commands.py | 134 ++++++++++++++++++++++++++++++++++++++++
3 files changed, 136 insertions(+), 9 deletions(-)
create mode 100644 tests/test_commands.py
diff --git a/.gitignore b/.gitignore
index 36dbfc2..fd59029 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,5 +17,4 @@ docs/
__pycache__/
poetry.lock
.pytest_cache/
-tests/
botpy.log
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 3ced25e..4e61deb 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -177,18 +177,12 @@ def onboard():
# Create workspace
workspace = get_workspace_path()
- create_templates = True
- if workspace.exists():
- console.print(f"[yellow]Workspace already exists at {workspace}[/yellow]")
- if not typer.confirm("Create missing default templates? (will not overwrite existing files)"):
- create_templates = False
- else:
+ if not workspace.exists():
workspace.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓[/green] Created workspace at {workspace}")
# Create default bootstrap files
- if create_templates:
- _create_workspace_templates(workspace)
+ _create_workspace_templates(workspace)
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
diff --git a/tests/test_commands.py b/tests/test_commands.py
new file mode 100644
index 0000000..462973f
--- /dev/null
+++ b/tests/test_commands.py
@@ -0,0 +1,134 @@
+import os
+import shutil
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+from typer.testing import CliRunner
+
+from nanobot.cli.commands import app
+
+runner = CliRunner()
+
+
+@pytest.fixture
+def mock_paths():
+ """Mock configuration and workspace paths for isolation."""
+ with patch("nanobot.config.loader.get_config_path") as mock_config_path, \
+ patch("nanobot.config.loader.save_config") as mock_save_config, \
+ patch("nanobot.utils.helpers.get_workspace_path") as mock_ws_path:
+
+ # Create temporary paths
+ base_dir = Path("./test_onboard_data")
+ if base_dir.exists():
+ shutil.rmtree(base_dir)
+ base_dir.mkdir()
+
+ config_file = base_dir / "config.json"
+ workspace_dir = base_dir / "workspace"
+
+ mock_config_path.return_value = config_file
+ mock_ws_path.return_value = workspace_dir
+
+ # We need save_config to actually write the file for existence checks to work
+ def side_effect_save_config(config):
+ with open(config_file, "w") as f:
+ f.write("{}")
+
+ mock_save_config.side_effect = side_effect_save_config
+
+ yield config_file, workspace_dir
+
+ # Cleanup
+ if base_dir.exists():
+ shutil.rmtree(base_dir)
+
+
+def test_onboard_fresh_install(mock_paths):
+ """Test onboarding with no existing files."""
+ config_file, workspace_dir = mock_paths
+
+ result = runner.invoke(app, ["onboard"])
+
+ assert result.exit_code == 0
+ assert "Created config" in result.stdout
+ assert "Created workspace" in result.stdout
+ assert "nanobot is ready" in result.stdout
+
+ assert config_file.exists()
+ assert workspace_dir.exists()
+ assert (workspace_dir / "AGENTS.md").exists()
+ assert (workspace_dir / "memory" / "MEMORY.md").exists()
+
+
+def test_onboard_existing_config_no_overwrite(mock_paths):
+ """Test onboarding with existing config, user declines overwrite."""
+ config_file, workspace_dir = mock_paths
+
+ # Pre-create config
+ config_file.write_text('{"existing": true}')
+
+ # Input "n" for overwrite prompt
+ result = runner.invoke(app, ["onboard"], input="n\n")
+
+ assert result.exit_code == 0
+ assert "Config already exists" in result.stdout
+
+ # Verify config was NOT changed
+ assert '{"existing": true}' in config_file.read_text()
+
+ # Verify workspace was still created
+ assert "Created workspace" in result.stdout
+ assert workspace_dir.exists()
+ assert (workspace_dir / "AGENTS.md").exists()
+
+
+def test_onboard_existing_config_overwrite(mock_paths):
+ """Test onboarding with existing config, user checks overwrite."""
+ config_file, workspace_dir = mock_paths
+
+ # Pre-create config
+ config_file.write_text('{"existing": true}')
+
+ # Input "y" for overwrite prompt
+ result = runner.invoke(app, ["onboard"], input="y\n")
+
+ assert result.exit_code == 0
+ assert "Config already exists" in result.stdout
+ assert "Created config" in result.stdout
+
+ # Verify config WAS changed (our mock writes "{}")
+ test_content = config_file.read_text()
+ assert test_content == "{}" or test_content == ""
+
+ assert workspace_dir.exists()
+
+
+def test_onboard_existing_workspace_safe_create(mock_paths):
+ """Test onboarding with existing workspace safely creates templates without prompting."""
+ config_file, workspace_dir = mock_paths
+
+ # Pre-create workspace
+ workspace_dir.mkdir(parents=True)
+
+ # Scenario: Config exists (keep it), Workspace exists (add templates automatically)
+ config_file.write_text("{}")
+
+ inputs = "n\n" # No overwrite config
+ result = runner.invoke(app, ["onboard"], input=inputs)
+
+ assert result.exit_code == 0
+ # Workspace exists message
+ # Depending on implementation, it might say "Workspace already exists" or just proceed.
+ # Code in commands.py Line 180: if not workspace.exists(): ...
+ # It does NOT print "Workspace already exists" if it exists.
+ # It only prints "Created workspace" if it created it.
+
+ assert "Created workspace" not in result.stdout
+
+ # Should NOT prompt for templates
+ assert "Create missing default templates?" not in result.stdout
+
+ # But SHOULD create them (since _create_workspace_templates is called unconditionally)
+ assert "Created AGENTS.md" in result.stdout
+ assert (workspace_dir / "AGENTS.md").exists()
From bd55bf527837fbdea31449b98e9a85150e5c69ea Mon Sep 17 00:00:00 2001
From: Luke Milby
Date: Fri, 13 Feb 2026 08:56:37 -0500
Subject: [PATCH 100/506] cleaned up logic for onboarding
---
nanobot/cli/commands.py | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index d776871..e48865f 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -163,12 +163,11 @@ def onboard():
if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
- if typer.confirm("Overwrite?"):
- # Create default config
- config = Config()
- save_config(config)
- console.print(f"[green]✓[/green] Created config at {config_path}")
- else:
+ if not typer.confirm("Overwrite?"):
+ console.print("[dim]Skipping config creation[/dim]")
+ config_path = None # Sentinel to skip creation
+
+ if config_path:
# Create default config
config = Config()
save_config(config)
From a3f4bb74fff5d7220ed4abb5401dc426f2c33360 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Fri, 13 Feb 2026 22:10:39 +0800
Subject: [PATCH 101/506] fix: increase max_messages to 500 as temporary
workaround
Temporarily increase default max_messages from 50 to 500 to allow
more context in conversations until a proper consolidation strategy
is implemented.
---
nanobot/session/manager.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 9549fd2..9fce9ee 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -41,7 +41,7 @@ class Session:
self.messages.append(msg)
self.updated_at = datetime.now()
- def get_history(self, max_messages: int = 50) -> list[dict[str, Any]]:
+ def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
"""
Get message history for LLM context.
From b76cf05c3af806624424002905c36f80a0d190a9 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 13 Feb 2026 16:05:00 +0000
Subject: [PATCH 102/506] feat: add custom provider and non-destructive onboard
---
nanobot/cli/commands.py | 18 +++++++++---------
nanobot/config/schema.py | 1 +
nanobot/providers/registry.py | 14 ++++++++++++++
3 files changed, 24 insertions(+), 9 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 92c017e..e540b27 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -155,21 +155,21 @@ def main(
@app.command()
def onboard():
"""Initialize nanobot configuration and workspace."""
- from nanobot.config.loader import get_config_path, save_config
+ 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
config_path = get_config_path()
if config_path.exists():
- console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
- if not typer.confirm("Overwrite?"):
- raise typer.Exit()
-
- # Create default config
- config = Config()
- save_config(config)
- console.print(f"[green]✓[/green] Created config at {config_path}")
+ # Load existing config — Pydantic fills in defaults for any new fields
+ config = load_config()
+ save_config(config)
+ console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
+ else:
+ config = Config()
+ save_config(config)
+ console.print(f"[green]✓[/green] Created config at {config_path}")
# Create workspace
workspace = get_workspace_path()
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index ef999b7..60bbc69 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -179,6 +179,7 @@ class ProviderConfig(BaseModel):
class ProvidersConfig(BaseModel):
"""Configuration for LLM providers."""
+ 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)
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index fdd036e..b9071a0 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -62,6 +62,20 @@ class ProviderSpec:
PROVIDERS: tuple[ProviderSpec, ...] = (
+ # === Custom (user-provided OpenAI-compatible endpoint) =================
+ # No auto-detection — only activates when user explicitly configures "custom".
+
+ ProviderSpec(
+ name="custom",
+ keywords=(),
+ env_key="OPENAI_API_KEY",
+ display_name="Custom",
+ litellm_prefix="openai",
+ skip_prefixes=("openai/",),
+ is_gateway=True,
+ strip_model_prefix=True,
+ ),
+
# === Gateways (detected by api_key / api_base, not model name) =========
# Gateways can route any model, so they win in fallback.
From 10e9e0cdc9ecc75fd360830abcc3c9f8ccfc20e5 Mon Sep 17 00:00:00 2001
From: The Mavik <179817126+themavik@users.noreply.github.com>
Date: Fri, 13 Feb 2026 17:08:10 -0500
Subject: [PATCH 103/506] fix(providers): clamp max_tokens to >= 1 before
calling LiteLLM (#523)
---
nanobot/providers/litellm_provider.py | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 7865139..a39893b 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -122,6 +122,10 @@ class LiteLLMProvider(LLMProvider):
"""
model = self._resolve_model(model or self.default_model)
+ # Clamp max_tokens to at least 1 — negative or zero values cause
+ # LiteLLM to reject the request with "max_tokens must be at least 1".
+ max_tokens = max(1, max_tokens)
+
kwargs: dict[str, Any] = {
"model": model,
"messages": messages,
From 12540ba8cba90f98cca9cc254fba031be40b2534 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 14 Feb 2026 00:58:43 +0000
Subject: [PATCH 104/506] feat: improve onboard with merge-or-overwrite prompt
---
README.md | 2 +-
nanobot/cli/commands.py | 18 ++++++++++++------
2 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 207df82..b5fdffa 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,583 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 18c23b1..b8d58df 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -162,13 +162,19 @@ def onboard():
config_path = get_config_path()
if config_path.exists():
- # Load existing config — Pydantic fills in defaults for any new fields
- config = load_config()
- save_config(config)
- console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
+ 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()
+ save_config(config)
+ console.print(f"[green]✓[/green] Config reset to defaults at {config_path}")
+ else:
+ config = load_config()
+ save_config(config)
+ console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
else:
- config = Config()
- save_config(config)
+ save_config(Config())
console.print(f"[green]✓[/green] Created config at {config_path}")
# Create workspace
From 3b580fd6c8ecb8d7f58740749bc3e3328fbe729c Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 14 Feb 2026 01:02:58 +0000
Subject: [PATCH 105/506] tests: update test_commands.py
---
tests/test_commands.py | 100 ++++++++++++-----------------------------
1 file changed, 29 insertions(+), 71 deletions(-)
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 462973f..f5495fd 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -1,7 +1,6 @@
-import os
import shutil
from pathlib import Path
-from unittest.mock import MagicMock, patch
+from unittest.mock import patch
import pytest
from typer.testing import CliRunner
@@ -13,122 +12,81 @@ runner = CliRunner()
@pytest.fixture
def mock_paths():
- """Mock configuration and workspace paths for isolation."""
- with patch("nanobot.config.loader.get_config_path") as mock_config_path, \
- patch("nanobot.config.loader.save_config") as mock_save_config, \
- patch("nanobot.utils.helpers.get_workspace_path") as mock_ws_path:
-
- # Create temporary paths
+ """Mock config/workspace paths for test isolation."""
+ with patch("nanobot.config.loader.get_config_path") as mock_cp, \
+ patch("nanobot.config.loader.save_config") as mock_sc, \
+ patch("nanobot.config.loader.load_config") as mock_lc, \
+ patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
+
base_dir = Path("./test_onboard_data")
if base_dir.exists():
shutil.rmtree(base_dir)
base_dir.mkdir()
-
+
config_file = base_dir / "config.json"
workspace_dir = base_dir / "workspace"
-
- mock_config_path.return_value = config_file
- mock_ws_path.return_value = workspace_dir
-
- # We need save_config to actually write the file for existence checks to work
- def side_effect_save_config(config):
- with open(config_file, "w") as f:
- f.write("{}")
- mock_save_config.side_effect = side_effect_save_config
-
+ mock_cp.return_value = config_file
+ mock_ws.return_value = workspace_dir
+ mock_sc.side_effect = lambda config: config_file.write_text("{}")
+
yield config_file, workspace_dir
-
- # Cleanup
+
if base_dir.exists():
shutil.rmtree(base_dir)
def test_onboard_fresh_install(mock_paths):
- """Test onboarding with no existing files."""
+ """No existing config — should create from scratch."""
config_file, workspace_dir = mock_paths
-
+
result = runner.invoke(app, ["onboard"])
-
+
assert result.exit_code == 0
assert "Created config" in result.stdout
assert "Created workspace" in result.stdout
assert "nanobot is ready" in result.stdout
-
assert config_file.exists()
- assert workspace_dir.exists()
assert (workspace_dir / "AGENTS.md").exists()
assert (workspace_dir / "memory" / "MEMORY.md").exists()
-def test_onboard_existing_config_no_overwrite(mock_paths):
- """Test onboarding with existing config, user declines overwrite."""
+def test_onboard_existing_config_refresh(mock_paths):
+ """Config exists, user declines overwrite — should refresh (load-merge-save)."""
config_file, workspace_dir = mock_paths
-
- # Pre-create config
config_file.write_text('{"existing": true}')
-
- # Input "n" for overwrite prompt
+
result = runner.invoke(app, ["onboard"], input="n\n")
-
+
assert result.exit_code == 0
assert "Config already exists" in result.stdout
-
- # Verify config was NOT changed
- assert '{"existing": true}' in config_file.read_text()
-
- # Verify workspace was still created
- assert "Created workspace" in result.stdout
+ assert "existing values preserved" in result.stdout
assert workspace_dir.exists()
assert (workspace_dir / "AGENTS.md").exists()
def test_onboard_existing_config_overwrite(mock_paths):
- """Test onboarding with existing config, user checks overwrite."""
+ """Config exists, user confirms overwrite — should reset to defaults."""
config_file, workspace_dir = mock_paths
-
- # Pre-create config
config_file.write_text('{"existing": true}')
-
- # Input "y" for overwrite prompt
+
result = runner.invoke(app, ["onboard"], input="y\n")
-
+
assert result.exit_code == 0
assert "Config already exists" in result.stdout
- assert "Created config" in result.stdout
-
- # Verify config WAS changed (our mock writes "{}")
- test_content = config_file.read_text()
- assert test_content == "{}" or test_content == ""
-
+ assert "Config reset to defaults" in result.stdout
assert workspace_dir.exists()
def test_onboard_existing_workspace_safe_create(mock_paths):
- """Test onboarding with existing workspace safely creates templates without prompting."""
+ """Workspace exists — should not recreate, but still add missing templates."""
config_file, workspace_dir = mock_paths
-
- # Pre-create workspace
workspace_dir.mkdir(parents=True)
-
- # Scenario: Config exists (keep it), Workspace exists (add templates automatically)
config_file.write_text("{}")
-
- inputs = "n\n" # No overwrite config
- result = runner.invoke(app, ["onboard"], input=inputs)
-
+
+ result = runner.invoke(app, ["onboard"], input="n\n")
+
assert result.exit_code == 0
- # Workspace exists message
- # Depending on implementation, it might say "Workspace already exists" or just proceed.
- # Code in commands.py Line 180: if not workspace.exists(): ...
- # It does NOT print "Workspace already exists" if it exists.
- # It only prints "Created workspace" if it created it.
-
assert "Created workspace" not in result.stdout
-
- # Should NOT prompt for templates
- assert "Create missing default templates?" not in result.stdout
-
- # But SHOULD create them (since _create_workspace_templates is called unconditionally)
assert "Created AGENTS.md" in result.stdout
assert (workspace_dir / "AGENTS.md").exists()
From d6d73c8167e09d34fe819db1d9f571b0fefa63bf Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 14 Feb 2026 01:03:16 +0000
Subject: [PATCH 106/506] docs: update .gitignore to remove tests
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 0f26d84..d7b930d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,4 @@ __pycache__/
poetry.lock
.pytest_cache/
botpy.log
+tests/
From 2f2c55f921b5d207f2eb03c72f4051db18054a72 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 14 Feb 2026 01:13:49 +0000
Subject: [PATCH 107/506] fix: add missing comma and type annotation for
temperature param
---
nanobot/agent/loop.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 775ede6..22e3315 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -42,7 +42,7 @@ class AgentLoop:
workspace: Path,
model: str | None = None,
max_iterations: int = 20,
- temperature = 0.7
+ temperature: float = 0.7,
memory_window: int = 50,
brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None,
From 59d5e3cc4f113abaa22de45056b39d4bce0220b3 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 14 Feb 2026 01:14:47 +0000
Subject: [PATCH 108/506] docs: update line count
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index b5fdffa..aeb6bfc 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,583 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,587 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
From f821e95d3c738a04a1b23406157a9f66f2b6fbb6 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 14 Feb 2026 01:40:37 +0000
Subject: [PATCH 109/506] fix: wire max_tokens/temperature to all chat calls,
clean up redundant comments
---
README.md | 2 +-
nanobot/agent/loop.py | 62 ++++++++------------------------------
nanobot/agent/subagent.py | 6 ++++
nanobot/cli/commands.py | 4 ++-
nanobot/session/manager.py | 28 ++++-------------
5 files changed, 28 insertions(+), 74 deletions(-)
diff --git a/README.md b/README.md
index aeb6bfc..e73beb5 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,587 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,536 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 091d205..c256a56 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -2,7 +2,6 @@
import asyncio
import json
-from datetime import datetime
from pathlib import Path
from typing import Any
@@ -44,6 +43,7 @@ class AgentLoop:
model: str | None = None,
max_iterations: int = 20,
temperature: float = 0.7,
+ max_tokens: int = 4096,
memory_window: int = 50,
brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None,
@@ -59,6 +59,7 @@ class AgentLoop:
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()
@@ -66,8 +67,6 @@ class AgentLoop:
self.restrict_to_workspace = restrict_to_workspace
self.context = ContextBuilder(workspace)
-
- # Initialize session manager
self.sessions = session_manager or SessionManager(workspace)
self.tools = ToolRegistry()
self.subagents = SubagentManager(
@@ -75,6 +74,8 @@ class AgentLoop:
workspace=workspace,
bus=bus,
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,
@@ -152,6 +153,7 @@ class AgentLoop:
tools=self.tools.get_definitions(),
model=self.model,
temperature=self.temperature,
+ max_tokens=self.max_tokens,
)
if response.has_tool_calls:
@@ -193,20 +195,16 @@ class AgentLoop:
while self._running:
try:
- # Wait for next message
msg = await asyncio.wait_for(
self.bus.consume_inbound(),
timeout=1.0
)
-
- # Process it
try:
response = await self._process_message(msg)
if response:
await self.bus.publish_outbound(response)
except Exception as e:
logger.error(f"Error processing message: {e}")
- # Send error response
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
@@ -231,15 +229,13 @@ class AgentLoop:
Returns:
The response message, or None if no response needed.
"""
- # Handle system messages (subagent announces)
- # The chat_id contains the original "channel:chat_id" to route back to
+ # System messages route back via chat_id ("channel:chat_id")
if msg.channel == "system":
return await self._process_system_message(msg)
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
- # Get or create session
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
@@ -250,12 +246,9 @@ class AgentLoop:
messages_to_archive = session.messages.copy()
session.clear()
self.sessions.save(session)
- # Clear cache to force reload from disk on next request
- self.sessions._cache.pop(session.key, None)
+ self.sessions.invalidate(session.key)
- # Consolidate in background (non-blocking)
async def _consolidate_and_cleanup():
- # Create a temporary session with archived messages
temp_session = Session(key=session.key)
temp_session.messages = messages_to_archive
await self._consolidate_memory(temp_session, archive_all=True)
@@ -267,34 +260,25 @@ class AgentLoop:
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
- # Consolidate memory before processing if session is too large
- # Run in background to avoid blocking main conversation
if len(session.messages) > self.memory_window:
asyncio.create_task(self._consolidate_memory(session))
- # Update tool contexts
self._set_tool_context(msg.channel, msg.chat_id)
-
- # Build initial messages
initial_messages = self.context.build_messages(
- history=session.get_history(),
+ history=session.get_history(max_messages=self.memory_window),
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel,
chat_id=msg.chat_id,
)
-
- # Run agent loop
final_content, tools_used = await self._run_agent_loop(initial_messages)
if final_content is None:
final_content = "I've completed processing but have no response to give."
- # Log response preview
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
- # Save to session (include tool names so consolidation sees what happened)
session.add_message("user", msg.content)
session.add_message("assistant", final_content,
tools_used=tools_used if tools_used else None)
@@ -326,28 +310,20 @@ class AgentLoop:
origin_channel = "cli"
origin_chat_id = msg.chat_id
- # Use the origin session for context
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
-
- # Update tool contexts
self._set_tool_context(origin_channel, origin_chat_id)
-
- # Build messages with the announce content
initial_messages = self.context.build_messages(
- history=session.get_history(),
+ history=session.get_history(max_messages=self.memory_window),
current_message=msg.content,
channel=origin_channel,
chat_id=origin_chat_id,
)
-
- # Run agent loop
final_content, _ = await self._run_agent_loop(initial_messages)
if final_content is None:
final_content = "Background task completed."
- # Save to session (mark as system message in history)
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self.sessions.save(session)
@@ -367,33 +343,26 @@ class AgentLoop:
"""
memory = MemoryStore(self.workspace)
- # Handle /new command: clear session and consolidate everything
if archive_all:
- old_messages = session.messages # All messages
- keep_count = 0 # Clear everything
+ old_messages = session.messages
+ keep_count = 0
logger.info(f"Memory consolidation (archive_all): {len(session.messages)} total messages archived")
else:
- # Normal consolidation: only write files, keep session intact
keep_count = self.memory_window // 2
-
- # Check if consolidation is needed
if len(session.messages) <= keep_count:
logger.debug(f"Session {session.key}: No consolidation needed (messages={len(session.messages)}, keep={keep_count})")
return
- # Use last_consolidated to avoid re-processing messages
messages_to_process = len(session.messages) - session.last_consolidated
if messages_to_process <= 0:
logger.debug(f"Session {session.key}: No new messages to consolidate (last_consolidated={session.last_consolidated}, total={len(session.messages)})")
return
- # Get messages to consolidate (from last_consolidated to keep_count from end)
old_messages = session.messages[session.last_consolidated:-keep_count]
if not old_messages:
return
logger.info(f"Memory consolidation started: {len(session.messages)} total, {len(old_messages)} new to consolidate, {keep_count} keep")
- # Format messages for LLM (include tool names when available)
lines = []
for m in old_messages:
if not m.get("content"):
@@ -436,18 +405,11 @@ Respond with ONLY valid JSON, no markdown fences."""
if update != current_memory:
memory.write_long_term(update)
- # Update last_consolidated to track what's been processed
if archive_all:
- # /new command: reset to 0 after clearing
session.last_consolidated = 0
else:
- # Normal: mark up to (total - keep_count) as consolidated
session.last_consolidated = len(session.messages) - keep_count
-
- # Key: We do NOT modify session.messages (append-only for cache)
- # The consolidation is only for human-readable files (MEMORY.md/HISTORY.md)
- # LLM cache remains intact because the messages list is unchanged
- logger.info(f"Memory consolidation done: {len(session.messages)} total messages (unchanged), last_consolidated={session.last_consolidated}")
+ logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}")
except Exception as e:
logger.error(f"Memory consolidation failed: {e}")
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 9e0cd7c..203836a 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -32,6 +32,8 @@ class SubagentManager:
workspace: Path,
bus: MessageBus,
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,
@@ -41,6 +43,8 @@ class SubagentManager:
self.workspace = workspace
self.bus = bus
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
@@ -130,6 +134,8 @@ class SubagentManager:
messages=messages,
tools=tools.get_definitions(),
model=self.model,
+ temperature=self.temperature,
+ max_tokens=self.max_tokens,
)
if response.has_tool_calls:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index daaaa97..17210ce 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -338,6 +338,7 @@ def gateway(
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,
@@ -445,8 +446,9 @@ def agent(
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
- max_iterations=config.agents.defaults.max_tool_iterations,
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,
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 9fce9ee..bce12a1 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -42,23 +42,8 @@ class Session:
self.updated_at = datetime.now()
def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
- """
- Get message history for LLM context.
-
- Messages are returned in append-only order for cache efficiency.
- Only the most recent max_messages are returned, but the order
- is always stable for the same max_messages value.
-
- Args:
- max_messages: Maximum messages to return (most recent).
-
- Returns:
- List of messages in LLM format (role and content only).
- """
- recent = self.messages[-max_messages:]
-
- # Convert to LLM format (just role and content)
- return [{"role": m["role"], "content": m["content"]} for m in recent]
+ """Get recent messages in LLM format (role + content only)."""
+ return [{"role": m["role"], "content": m["content"]} for m in self.messages[-max_messages:]]
def clear(self) -> None:
"""Clear all messages and reset session to initial state."""
@@ -94,11 +79,9 @@ class SessionManager:
Returns:
The session.
"""
- # Check cache
if key in self._cache:
return self._cache[key]
- # Try to load from disk
session = self._load(key)
if session is None:
session = Session(key=key)
@@ -150,7 +133,6 @@ class SessionManager:
path = self._get_session_path(session.key)
with open(path, "w") as f:
- # Write metadata first
metadata_line = {
"_type": "metadata",
"created_at": session.created_at.isoformat(),
@@ -159,13 +141,15 @@ class SessionManager:
"last_consolidated": session.last_consolidated
}
f.write(json.dumps(metadata_line) + "\n")
-
- # Write messages
for msg in session.messages:
f.write(json.dumps(msg) + "\n")
self._cache[session.key] = session
+ def invalidate(self, key: str) -> None:
+ """Remove a session from the in-memory cache."""
+ self._cache.pop(key, None)
+
def list_sessions(self) -> list[dict[str, Any]]:
"""
List all sessions.
From 153c83e340c518209cfe879f296dc99f742ad778 Mon Sep 17 00:00:00 2001
From: Ahwei
Date: Sat, 14 Feb 2026 10:23:54 +0800
Subject: [PATCH 110/506] fix(cron): add timezone support for accurate next run
time calculation
When schedule.tz is present, use the specified timezone to calculate the next execution time, ensuring scheduled tasks trigger correctly across different timezones.
---
nanobot/cron/service.py | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index d1965a9..6fea4de 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -4,6 +4,7 @@ import asyncio
import json
import time
import uuid
+from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Coroutine
@@ -30,9 +31,18 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
if schedule.kind == "cron" and schedule.expr:
try:
from croniter import croniter
- cron = croniter(schedule.expr, time.time())
- next_time = cron.get_next()
- return int(next_time * 1000)
+ from zoneinfo import ZoneInfo
+ base_time = time.time()
+ if schedule.tz:
+ tz = ZoneInfo(schedule.tz)
+ 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)
+ else:
+ cron = croniter(schedule.expr, base_time)
+ next_time = cron.get_next()
+ return int(next_time * 1000)
except Exception:
return None
From d3f6c95cebaf17d04f0d04655a98c0e795777bb1 Mon Sep 17 00:00:00 2001
From: Ahwei
Date: Sat, 14 Feb 2026 10:27:09 +0800
Subject: [PATCH 111/506] refactor(cron): simplify timezone logic and merge
conditional branches
With tz: Use the specified timezone (e.g., "Asia/Shanghai").
Without tz: Use the local timezone (datetime.now().astimezone().tzinfo) instead of defaulting to UTC
---
nanobot/cron/service.py | 15 +++++----------
1 file changed, 5 insertions(+), 10 deletions(-)
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index 6fea4de..4da845a 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -33,16 +33,11 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
from croniter import croniter
from zoneinfo import ZoneInfo
base_time = time.time()
- if schedule.tz:
- tz = ZoneInfo(schedule.tz)
- 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)
- else:
- cron = croniter(schedule.expr, base_time)
- next_time = cron.get_next()
- return int(next_time * 1000)
+ 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:
return None
From 4e4eb21d23bbeef284438e252e9a88acef973f50 Mon Sep 17 00:00:00 2001
From: Ahwei
Date: Sat, 14 Feb 2026 12:14:31 +0800
Subject: [PATCH 112/506] feat(feishu): Add rich text message content
extraction feature
Newly added the _extract_post_text function to extract plain text content from Feishu rich text messages, supporting the parsing of titles, text, links, and @mentions.
---
nanobot/channels/feishu.py | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 9017b40..5d6c33b 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -39,6 +39,35 @@ MSG_TYPE_MAP = {
}
+def _extract_post_text(content_json: dict) -> str:
+ """Extract plain text from Feishu post (rich text) message content."""
+ for lang_key in ("zh_cn", "en_us", "ja_jp"):
+ lang_content = content_json.get(lang_key)
+ if not isinstance(lang_content, dict):
+ continue
+ title = lang_content.get("title", "")
+ content_blocks = lang_content.get("content", [])
+ if not isinstance(content_blocks, list):
+ continue
+ 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()
+ return ""
+
+
class FeishuChannel(BaseChannel):
"""
Feishu/Lark channel using WebSocket long connection.
@@ -326,6 +355,12 @@ class FeishuChannel(BaseChannel):
content = json.loads(message.content).get("text", "")
except json.JSONDecodeError:
content = message.content or ""
+ elif msg_type == "post":
+ try:
+ content_json = json.loads(message.content)
+ content = _extract_post_text(content_json)
+ except (json.JSONDecodeError, TypeError):
+ content = message.content or ""
else:
content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")
From 5e082690d8bee4ca6f2bfe6881fc5a0579262dd5 Mon Sep 17 00:00:00 2001
From: Ahwei
Date: Sat, 14 Feb 2026 14:37:23 +0800
Subject: [PATCH 113/506] refactor(feishu): support both direct and localized
post content formats
---
nanobot/channels/feishu.py | 30 ++++++++++++++++++++++++------
1 file changed, 24 insertions(+), 6 deletions(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 5d6c33b..bc4a2b8 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -40,15 +40,19 @@ MSG_TYPE_MAP = {
def _extract_post_text(content_json: dict) -> str:
- """Extract plain text from Feishu post (rich text) message content."""
- for lang_key in ("zh_cn", "en_us", "ja_jp"):
- lang_content = content_json.get(lang_key)
+ """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):
- continue
+ return None
title = lang_content.get("title", "")
content_blocks = lang_content.get("content", [])
if not isinstance(content_blocks, list):
- continue
+ return None
text_parts = []
if title:
text_parts.append(title)
@@ -64,7 +68,21 @@ def _extract_post_text(content_json: dict) -> str:
text_parts.append(element.get("text", ""))
elif tag == "at":
text_parts.append(f"@{element.get('user_name', 'user')}")
- return " ".join(text_parts).strip()
+ 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 ""
From 66cd21e6eccdba245c4b99a2c5e1fd3b2f791995 Mon Sep 17 00:00:00 2001
From: Zhiwei Li
Date: Sat, 14 Feb 2026 20:21:34 +1100
Subject: [PATCH 114/506] feat: add SiliconFlow provider support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add SiliconFlow (硅基流动) as an OpenAI-compatible gateway provider.
SiliconFlow hosts multiple models (Qwen, DeepSeek, etc.) via an
OpenAI-compatible API at https://api.siliconflow.cn/v1.
Changes:
- Add ProviderSpec for siliconflow in providers/registry.py
- Add siliconflow field to ProvidersConfig in config/schema.py
Co-authored-by: Cursor
---
nanobot/config/schema.py | 1 +
nanobot/providers/registry.py | 21 +++++++++++++++++++++
2 files changed, 22 insertions(+)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 60bbc69..6140a65 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -192,6 +192,7 @@ class ProvidersConfig(BaseModel):
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) # 硅基流动 API gateway
class GatewayConfig(BaseModel):
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index b9071a0..bf00e31 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -117,6 +117,27 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
model_overrides=(),
),
+ # SiliconFlow (硅基流动): OpenAI-compatible gateway hosting multiple models.
+ # strip_model_prefix=False: SiliconFlow model names include org prefix
+ # (e.g. "Qwen/Qwen2.5-14B-Instruct", "deepseek-ai/DeepSeek-V3")
+ # which is part of the model ID and must NOT be stripped.
+ ProviderSpec(
+ name="siliconflow",
+ keywords=("siliconflow",),
+ env_key="OPENAI_API_KEY", # OpenAI-compatible
+ display_name="SiliconFlow",
+ litellm_prefix="openai", # → openai/{model}
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=True,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="siliconflow",
+ default_api_base="https://api.siliconflow.cn/v1",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
# === Standard providers (matched by model-name keywords) ===============
# Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed.
From b523b277b05c6ed1c51c5ea44364787b71e3c592 Mon Sep 17 00:00:00 2001
From: Harry Zhou
Date: Sat, 14 Feb 2026 23:44:03 +0800
Subject: [PATCH 115/506] fix(agent): handle non-string values in memory
consolidation
Fix TypeError when LLM returns JSON objects instead of strings for
history_entry or memory_update.
Changes:
- Update prompt to explicitly require string values with example
- Add type checking and conversion for non-string values
- Use json.dumps() for consistent JSON formatting
Fixes potential memory consolidation failures when LLM interprets
the prompt loosely and returns structured objects instead of strings.
---
nanobot/agent/loop.py | 14 +++
tests/test_memory_consolidation_types.py | 133 +++++++++++++++++++++++
2 files changed, 147 insertions(+)
create mode 100644 tests/test_memory_consolidation_types.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index c256a56..e28166f 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -384,6 +384,14 @@ class AgentLoop:
## Conversation to Process
{conversation}
+**IMPORTANT**: Both values MUST be strings, not objects or arrays.
+
+Example:
+{{
+ "history_entry": "[2026-02-14 22:50] User asked about...",
+ "memory_update": "- Host: HARRYBOOK-T14P\n- Name: Nado"
+}}
+
Respond with ONLY valid JSON, no markdown fences."""
try:
@@ -400,8 +408,14 @@ Respond with ONLY valid JSON, no markdown fences."""
result = json.loads(text)
if entry := result.get("history_entry"):
+ # Defensive: ensure entry is a string (LLM may return dict)
+ if not isinstance(entry, str):
+ entry = json.dumps(entry, ensure_ascii=False)
memory.append_history(entry)
if update := result.get("memory_update"):
+ # Defensive: ensure update is a string
+ if not isinstance(update, str):
+ update = json.dumps(update, ensure_ascii=False)
if update != current_memory:
memory.write_long_term(update)
diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py
new file mode 100644
index 0000000..3b76596
--- /dev/null
+++ b/tests/test_memory_consolidation_types.py
@@ -0,0 +1,133 @@
+"""Test memory consolidation handles non-string values from LLM.
+
+This test verifies the fix for the bug where memory consolidation fails
+when LLM returns JSON objects instead of strings for history_entry or
+memory_update fields.
+
+Related issue: Memory consolidation fails with TypeError when LLM returns dict
+"""
+
+import json
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from nanobot.agent.memory import MemoryStore
+
+
+class TestMemoryConsolidationTypeHandling:
+ """Test that MemoryStore methods handle type conversion correctly."""
+
+ def test_append_history_accepts_string(self):
+ """MemoryStore.append_history should accept string values."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ memory = MemoryStore(Path(tmpdir))
+
+ # Should not raise TypeError
+ memory.append_history("[2026-02-14] Test entry")
+
+ # Verify content was written
+ history_content = memory.history_file.read_text()
+ assert "Test entry" in history_content
+
+ def test_write_long_term_accepts_string(self):
+ """MemoryStore.write_long_term should accept string values."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ memory = MemoryStore(Path(tmpdir))
+
+ # Should not raise TypeError
+ memory.write_long_term("- Fact 1\n- Fact 2")
+
+ # Verify content was written
+ memory_content = memory.read_long_term()
+ assert "Fact 1" in memory_content
+
+ def test_type_conversion_dict_to_str(self):
+ """Dict values should be converted to JSON strings."""
+ input_val = {"timestamp": "2026-02-14", "summary": "test"}
+ expected = '{"timestamp": "2026-02-14", "summary": "test"}'
+
+ # Simulate the fix logic
+ if not isinstance(input_val, str):
+ result = json.dumps(input_val, ensure_ascii=False)
+ else:
+ result = input_val
+
+ assert result == expected
+ assert isinstance(result, str)
+
+ def test_type_conversion_list_to_str(self):
+ """List values should be converted to JSON strings."""
+ input_val = ["item1", "item2"]
+ expected = '["item1", "item2"]'
+
+ # Simulate the fix logic
+ if not isinstance(input_val, str):
+ result = json.dumps(input_val, ensure_ascii=False)
+ else:
+ result = input_val
+
+ assert result == expected
+ assert isinstance(result, str)
+
+ def test_type_conversion_str_unchanged(self):
+ """String values should remain unchanged."""
+ input_val = "already a string"
+
+ # Simulate the fix logic
+ if not isinstance(input_val, str):
+ result = json.dumps(input_val, ensure_ascii=False)
+ else:
+ result = input_val
+
+ assert result == input_val
+ assert isinstance(result, str)
+
+ def test_memory_consolidation_simulation(self):
+ """Simulate full consolidation with dict values from LLM."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ memory = MemoryStore(Path(tmpdir))
+
+ # Simulate LLM returning dict values (the bug scenario)
+ history_entry = {"timestamp": "2026-02-14", "summary": "User asked about..."}
+ memory_update = {"facts": ["Location: Beijing", "Skill: Python"]}
+
+ # Apply the fix: convert to str
+ if not isinstance(history_entry, str):
+ history_entry = json.dumps(history_entry, ensure_ascii=False)
+ if not isinstance(memory_update, str):
+ memory_update = json.dumps(memory_update, ensure_ascii=False)
+
+ # Should not raise TypeError after conversion
+ memory.append_history(history_entry)
+ memory.write_long_term(memory_update)
+
+ # Verify content
+ assert memory.history_file.exists()
+ assert memory.memory_file.exists()
+
+ history_content = memory.history_file.read_text()
+ memory_content = memory.read_long_term()
+
+ assert "timestamp" in history_content
+ assert "facts" in memory_content
+
+
+class TestPromptOptimization:
+ """Test that prompt optimization helps prevent the issue."""
+
+ def test_prompt_includes_string_requirement(self):
+ """The prompt should explicitly require string values."""
+ # This is a documentation test - verify the fix is in place
+ # by checking the expected prompt content
+ expected_keywords = [
+ "MUST be strings",
+ "not objects or arrays",
+ "Example:",
+ ]
+
+ # The actual prompt content is in nanobot/agent/loop.py
+ # This test serves as documentation of the expected behavior
+ for keyword in expected_keywords:
+ assert keyword, f"Prompt should include: {keyword}"
From fbbbdc727ddec942279a5d0ccc3c37b1bc08ab23 Mon Sep 17 00:00:00 2001
From: Oleg Medvedev
Date: Sat, 14 Feb 2026 13:38:49 -0600
Subject: [PATCH 116/506] fix(tools): resolve relative file paths against
workspace
File tools now resolve relative paths (e.g., "test.txt") against the
workspace directory instead of the current working directory. This fixes
failures when models use simple filenames instead of full paths.
- Add workspace parameter to _resolve_path() in filesystem.py
- Update all file tools to accept workspace in constructor
- Pass workspace when registering tools in AgentLoop
---
nanobot/agent/loop.py | 10 +++---
nanobot/agent/tools/filesystem.py | 59 +++++++++++++++++--------------
2 files changed, 38 insertions(+), 31 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index c256a56..3d9f77b 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -86,12 +86,12 @@ class AgentLoop:
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
- # File tools (restrict to workspace if configured)
+ # File tools (workspace for relative paths, restrict if configured)
allowed_dir = self.workspace if self.restrict_to_workspace else None
- self.tools.register(ReadFileTool(allowed_dir=allowed_dir))
- self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
- self.tools.register(EditFileTool(allowed_dir=allowed_dir))
- self.tools.register(ListDirTool(allowed_dir=allowed_dir))
+ self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
+ self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
+ self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
+ self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
# Shell tool
self.tools.register(ExecTool(
diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py
index 6b3254a..419b088 100644
--- a/nanobot/agent/tools/filesystem.py
+++ b/nanobot/agent/tools/filesystem.py
@@ -6,9 +6,12 @@ from typing import Any
from nanobot.agent.tools.base import Tool
-def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path:
- """Resolve path and optionally enforce directory restriction."""
- resolved = Path(path).expanduser().resolve()
+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 and not str(resolved).startswith(str(allowed_dir.resolve())):
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
return resolved
@@ -16,8 +19,9 @@ def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path:
class ReadFileTool(Tool):
"""Tool to read file contents."""
-
- def __init__(self, allowed_dir: Path | None = None):
+
+ def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
+ self._workspace = workspace
self._allowed_dir = allowed_dir
@property
@@ -43,12 +47,12 @@ class ReadFileTool(Tool):
async def execute(self, path: str, **kwargs: Any) -> str:
try:
- file_path = _resolve_path(path, self._allowed_dir)
+ 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:
@@ -59,8 +63,9 @@ class ReadFileTool(Tool):
class WriteFileTool(Tool):
"""Tool to write content to a file."""
-
- def __init__(self, allowed_dir: Path | None = None):
+
+ def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
+ self._workspace = workspace
self._allowed_dir = allowed_dir
@property
@@ -90,10 +95,10 @@ class WriteFileTool(Tool):
async def execute(self, path: str, content: str, **kwargs: Any) -> str:
try:
- file_path = _resolve_path(path, self._allowed_dir)
+ file_path = _resolve_path(path, self._workspace, self._allowed_dir)
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 {path}"
+ return f"Successfully wrote {len(content)} bytes to {file_path}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
@@ -102,8 +107,9 @@ class WriteFileTool(Tool):
class EditFileTool(Tool):
"""Tool to edit a file by replacing text."""
-
- def __init__(self, allowed_dir: Path | None = None):
+
+ def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
+ self._workspace = workspace
self._allowed_dir = allowed_dir
@property
@@ -137,24 +143,24 @@ class EditFileTool(Tool):
async def execute(self, path: str, old_text: str, new_text: str, **kwargs: Any) -> str:
try:
- file_path = _resolve_path(path, self._allowed_dir)
+ file_path = _resolve_path(path, self._workspace, self._allowed_dir)
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 f"Error: old_text not found in file. Make sure it matches exactly."
-
+
# 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 {path}"
+
+ return f"Successfully edited {file_path}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
@@ -163,8 +169,9 @@ class EditFileTool(Tool):
class ListDirTool(Tool):
"""Tool to list directory contents."""
-
- def __init__(self, allowed_dir: Path | None = None):
+
+ def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
+ self._workspace = workspace
self._allowed_dir = allowed_dir
@property
@@ -190,20 +197,20 @@ class ListDirTool(Tool):
async def execute(self, path: str, **kwargs: Any) -> str:
try:
- dir_path = _resolve_path(path, self._allowed_dir)
+ 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}"
From e2ef1f9d4846246472d2c8454b40d4dbf239dd4c Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 15 Feb 2026 06:02:45 +0000
Subject: [PATCH 117/506] docs: add custom provider guideline
---
README.md | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/README.md b/README.md
index e73beb5..47702c1 100644
--- a/README.md
+++ b/README.md
@@ -599,6 +599,7 @@ Config file: `~/.nanobot/config.json`
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
+| `custom` | Any OpenAI-compatible endpoint | — |
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
@@ -612,6 +613,31 @@ Config file: `~/.nanobot/config.json`
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
+
+Custom Provider (Any OpenAI-compatible API)
+
+If your provider is not listed above but exposes an **OpenAI-compatible API** (e.g. Together AI, Fireworks, Azure OpenAI, self-hosted endpoints), use the `custom` provider:
+
+```json
+{
+ "providers": {
+ "custom": {
+ "apiKey": "your-api-key",
+ "apiBase": "https://api.your-provider.com/v1"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "model": "your-model-name"
+ }
+ }
+}
+```
+
+> The `custom` provider routes through LiteLLM's OpenAI-compatible path. It works with any endpoint that follows the OpenAI chat completions API format. The model name is passed directly to the endpoint without any prefix.
+
+
+
Adding a New Provider (Developer Guide)
From 52cf1da30a408ac4ec91fac5e9249a72f01ee1d2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 15 Feb 2026 07:00:27 +0000
Subject: [PATCH 118/506] fix: store original MCP tool name, make close_mcp
public
---
README.md | 36 +++++++++++++++++++++++++++++++++++-
nanobot/agent/loop.py | 2 +-
nanobot/agent/tools/mcp.py | 6 ++----
nanobot/cli/commands.py | 6 +++---
4 files changed, 41 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 47702c1..c08d3af 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,536 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,656 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
@@ -683,6 +683,40 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
+### MCP (Model Context Protocol)
+
+> [!TIP]
+> The config format is compatible with Claude Desktop / Cursor. You can copy MCP server configs directly from any MCP server's README.
+
+nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external tool servers and use them as native agent tools.
+
+Add MCP servers to your `config.json`:
+
+```json
+{
+ "tools": {
+ "mcpServers": {
+ "filesystem": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
+ }
+ }
+ }
+}
+```
+
+Two transport modes are supported:
+
+| Mode | Config | Example |
+|------|--------|---------|
+| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
+| **HTTP** | `url` | Remote endpoint (`https://mcp.example.com/sse`) |
+
+MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
+
+
+
+
### Security
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index cc7a0d0..7deef59 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -229,7 +229,7 @@ class AgentLoop:
except asyncio.TimeoutError:
continue
- async def _close_mcp(self) -> None:
+ async def close_mcp(self) -> None:
"""Close MCP connections."""
if self._mcp_stack:
try:
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index bcef4aa..1c8eac4 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -14,7 +14,7 @@ class MCPToolWrapper(Tool):
def __init__(self, session, server_name: str, tool_def):
self._session = session
- self._server = server_name
+ self._original_name = tool_def.name
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": {}}
@@ -33,9 +33,7 @@ class MCPToolWrapper(Tool):
async def execute(self, **kwargs: Any) -> str:
from mcp import types
- result = await self._session.call_tool(
- self._name.removeprefix(f"mcp_{self._server}_"), arguments=kwargs
- )
+ result = await self._session.call_tool(self._original_name, arguments=kwargs)
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 34bfde8..6a9c92f 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -405,7 +405,7 @@ def gateway(
except KeyboardInterrupt:
console.print("\nShutting down...")
finally:
- await agent._close_mcp()
+ await agent.close_mcp()
heartbeat.stop()
cron.stop()
agent.stop()
@@ -473,7 +473,7 @@ def agent(
with _thinking_ctx():
response = await agent_loop.process_direct(message, session_id)
_print_agent_response(response, render_markdown=markdown)
- await agent_loop._close_mcp()
+ await agent_loop.close_mcp()
asyncio.run(run_once())
else:
@@ -515,7 +515,7 @@ def agent(
console.print("\nGoodbye!")
break
finally:
- await agent_loop._close_mcp()
+ await agent_loop.close_mcp()
asyncio.run(run_interactive())
From 49fec3684ad100e44203ded5fa2a3c2113d0094b Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 15 Feb 2026 08:11:33 +0000
Subject: [PATCH 119/506] fix: use json_repair for robust LLM response parsing
---
README.md | 2 +-
nanobot/agent/loop.py | 9 ++++++++-
nanobot/providers/litellm_provider.py | 6 ++----
pyproject.toml | 1 +
4 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index c08d3af..9066d5a 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,656 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,663 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 7deef59..6342f56 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -3,6 +3,7 @@
import asyncio
from contextlib import AsyncExitStack
import json
+import json_repair
from pathlib import Path
from typing import Any
@@ -420,9 +421,15 @@ Respond with ONLY valid JSON, no markdown fences."""
model=self.model,
)
text = (response.content or "").strip()
+ if not text:
+ logger.warning("Memory consolidation: LLM returned empty response, skipping")
+ return
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
- result = json.loads(text)
+ result = json_repair.loads(text)
+ if not isinstance(result, dict):
+ logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}")
+ return
if entry := result.get("history_entry"):
memory.append_history(entry)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index a39893b..ed4cf49 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -1,6 +1,7 @@
"""LiteLLM provider implementation for multi-provider support."""
import json
+import json_repair
import os
from typing import Any
@@ -173,10 +174,7 @@ class LiteLLMProvider(LLMProvider):
# Parse arguments from JSON string if needed
args = tc.function.arguments
if isinstance(args, str):
- try:
- args = json.loads(args)
- except json.JSONDecodeError:
- args = {"raw": args}
+ args = json_repair.loads(args)
tool_calls.append(ToolCallRequest(
id=tc.id,
diff --git a/pyproject.toml b/pyproject.toml
index 17c739f..147e799 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -39,6 +39,7 @@ dependencies = [
"python-socks[asyncio]>=2.4.0",
"prompt-toolkit>=3.0.0",
"mcp>=1.0.0",
+ "json-repair>=0.30.0",
]
[project.optional-dependencies]
From 82074a7715cd7e3b8c4810f861401926b64139cf Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 15 Feb 2026 14:03:51 +0000
Subject: [PATCH 120/506] docs: update news section
---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index 9066d5a..e75f080 100644
--- a/README.md
+++ b/README.md
@@ -20,8 +20,10 @@
## 📢 News
+- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details.
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
+- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
From 7e2d801ffc5071d68b07a30c06d8a3f97ce58e62 Mon Sep 17 00:00:00 2001
From: "Aleksander W. Oleszkiewicz (Alek)"
<24917047+alekwo@users.noreply.github.com>
Date: Sun, 15 Feb 2026 15:51:19 +0100
Subject: [PATCH 121/506] Implement markdown conversion for Slack messages
Add markdown conversion for Slack messages including italics, bold, and table formatting.
---
nanobot/channels/slack.py | 114 +++++++++++++++++++++++++++++++++++++-
1 file changed, 113 insertions(+), 1 deletion(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index be95dd2..7298435 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -84,7 +84,7 @@ class SlackChannel(BaseChannel):
use_thread = thread_ts and channel_type != "im"
await self._web_client.chat_postMessage(
channel=msg.chat_id,
- text=msg.content or "",
+ text=self._convert_markdown(msg.content) or "",
thread_ts=thread_ts if use_thread else None,
)
except Exception as e:
@@ -203,3 +203,115 @@ class SlackChannel(BaseChannel):
if not text or not self._bot_user_id:
return text
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
+
+ def _convert_markdown(self, text: str) -> str:
+ if not text:
+ return text
+ def convert_formatting(input: str) -> str:
+ # Convert italics
+ # Step 1: *text* -> _text_
+ converted_text = re.sub(
+ r"(?m)(^|[^\*])\*([^\*].+?[^\*])\*([^\*]|$)", r"\1_\2_\3", input)
+ # Convert bold
+ # Step 2.a: **text** -> *text*
+ converted_text = re.sub(
+ r"(?m)(^|[^\*])\*\*([^\*].+?[^\*])\*\*([^\*]|$)", r"\1*\2*\3", converted_text)
+ # Step 2.b: __text__ -> *text*
+ converted_text = re.sub(
+ r"(?m)(^|[^_])__([^_].+?[^_])__([^_]|$)", r"\1*\2*\3", converted_text)
+ # convert bold italics
+ # Step 3.a: ***text*** -> *_text_*
+ converted_text = re.sub(
+ r"(?m)(^|[^\*])\*\*\*([^\*].+?[^\*])\*\*\*([^\*]|$)", r"\1*_\2_*\3", converted_text)
+ # Step 3.b - ___text___ to *_text_*
+ converted_text = re.sub(
+ r"(?m)(^|[^_])___([^_].+?[^_])___([^_]|$)", r"\1*_\2_*\3", converted_text)
+ return converted_text
+ def escape_mrkdwn(text: str) -> str:
+ return (text.replace('&', '&')
+ .replace('<', '<')
+ .replace('>', '>'))
+ def convert_table(match: re.Match) -> str:
+ # Slack doesn't support Markdown tables
+ # Convert table to bulleted list with sections
+ # -- input_md:
+ # Some text before the table.
+ # | Col1 | Col2 | Col3 |
+ # |-----|----------|------|
+ # | Row1 - A | Row1 - B | Row1 - C |
+ # | Row2 - D | Row2 - E | Row2 - F |
+ #
+ # Some text after the table.
+ #
+ # -- will be converted to:
+ # Some text before the table.
+ # > *Col1* : Row1 - A
+ # • *Col2*: Row1 - B
+ # • *Col3*: Row1 - C
+ # > *Col1* : Row2 - D
+ # • *Col2*: Row2 - E
+ # • *Col3*: Row2 - F
+ #
+ # Some text after the table.
+
+ block = match.group(0).strip()
+ lines = [line.strip()
+ for line in block.split('\n') if line.strip()]
+
+ if len(lines) < 2:
+ return block
+
+ # 1. Parse Headers from the first line
+ # Split by pipe, filtering out empty start/end strings caused by outer pipes
+ header_line = lines[0].strip('|')
+ headers = [escape_mrkdwn(h.strip())
+ for h in header_line.split('|')]
+
+ # 2. Identify Data Start (Skip Separator)
+ data_start_idx = 1
+ # If line 2 contains only separator chars (|-: ), skip it
+ if len(lines) > 1 and not re.search(r'[^|\-\s:]', lines[1]):
+ data_start_idx = 2
+
+ # 3. Process Data Rows
+ slack_lines = []
+ for line in lines[data_start_idx:]:
+ # Clean and split cells
+ clean_line = line.strip('|')
+ cells = [escape_mrkdwn(c.strip())
+ for c in clean_line.split('|')]
+
+ # Normalize cell count to match headers
+ if len(cells) < len(headers):
+ cells += [''] * (len(headers) - len(cells))
+ cells = cells[:len(headers)]
+
+ # Skip empty rows
+ if not any(cells):
+ continue
+
+ # Key is the first column
+ key = cells[0]
+ label = headers[0]
+ slack_lines.append(
+ f"> *{label}* : {key}" if key else "> *{label}* : --")
+
+ # Sub-bullets for remaining columns
+ for i, cell in enumerate(cells[1:], 1):
+ if cell:
+ label = headers[i] if i < len(headers) else "Col"
+ slack_lines.append(f" • *{label}*: {cell}")
+
+ slack_lines.append("") # Spacer between items
+
+ return "\n".join(slack_lines).rstrip()
+
+ # (?m) : Multiline mode so ^ matches start of line and $ end of line
+ # ^\| : Start of line and a literal pipe
+ # .*?\|$ : Rest of the line and a pipe at the end
+ # (?:\n(?:\|\:?-{3,}\:?)*?\|$) : A heading line with at least three dashes in each column, pipes, and : e.g. |:---|----|:---:|
+ # (?:\n\|.*?\|$)* : Zero or more subsequent lines that ALSO start and end with a pipe
+ table_pattern = r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*'
+
+ input_md = convert_formatting(text)
+ return re.sub(table_pattern, convert_table, input_md)
From a5265c263d1ea277dc3197e63364105be0503d79 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 15 Feb 2026 16:41:27 +0000
Subject: [PATCH 122/506] docs: update readme structure
---
README.md | 90 ++++++++++++++++++++++++++++++-------------------------
1 file changed, 49 insertions(+), 41 deletions(-)
diff --git a/README.md b/README.md
index e75f080..c1b7e46 100644
--- a/README.md
+++ b/README.md
@@ -109,14 +109,22 @@ nanobot onboard
**2. Configure** (`~/.nanobot/config.json`)
-For OpenRouter - recommended for global users:
+Add or merge these **two parts** into your config (other options have defaults).
+
+*Set your API key* (e.g. OpenRouter, recommended for global users):
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-xxx"
}
- },
+ }
+}
+```
+
+*Set your model*:
+```json
+{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
@@ -128,48 +136,11 @@ For OpenRouter - recommended for global users:
**3. Chat**
```bash
-nanobot agent -m "What is 2+2?"
+nanobot agent
```
That's it! You have a working AI assistant in 2 minutes.
-## 🖥️ Local Models (vLLM)
-
-Run nanobot with your own local models using vLLM or any OpenAI-compatible server.
-
-**1. Start your vLLM server**
-
-```bash
-vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
-```
-
-**2. Configure** (`~/.nanobot/config.json`)
-
-```json
-{
- "providers": {
- "vllm": {
- "apiKey": "dummy",
- "apiBase": "http://localhost:8000/v1"
- }
- },
- "agents": {
- "defaults": {
- "model": "meta-llama/Llama-3.1-8B-Instruct"
- }
- }
-}
-```
-
-**3. Chat**
-
-```bash
-nanobot agent -m "Hello from my local LLM!"
-```
-
-> [!TIP]
-> The `apiKey` can be any non-empty string for local servers that don't require authentication.
-
## 💬 Chat Apps
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ — anytime, anywhere.
@@ -640,6 +611,43 @@ If your provider is not listed above but exposes an **OpenAI-compatible API** (e
+
+vLLM (local / OpenAI-compatible)
+
+Run your own model with vLLM or any OpenAI-compatible server, then add to config:
+
+**1. Start the server** (example):
+```bash
+vllm serve meta-llama/Llama-3.1-8B-Instruct --port 8000
+```
+
+**2. Add to config** (partial — merge into `~/.nanobot/config.json`):
+
+*Provider (key can be any non-empty string for local):*
+```json
+{
+ "providers": {
+ "vllm": {
+ "apiKey": "dummy",
+ "apiBase": "http://localhost:8000/v1"
+ }
+ }
+}
+```
+
+*Model:*
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "meta-llama/Llama-3.1-8B-Instruct"
+ }
+ }
+}
+```
+
+
+
Adding a New Provider (Developer Guide)
@@ -721,6 +729,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
### Security
+> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
| Option | Default | Description |
@@ -815,7 +824,6 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
**Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)!
-- [x] **Voice Transcription** — Support for Groq Whisper (Issue #13)
- [ ] **Multi-modal** — See and hear (images, voice, video)
- [ ] **Long-term memory** — Never forget important context
- [ ] **Better reasoning** — Multi-step planning and reflection
From 203aa154d4c26b5c49a07923b74a5d928bb14300 Mon Sep 17 00:00:00 2001
From: zhouzhuojie
Date: Sun, 15 Feb 2026 22:39:31 +0000
Subject: [PATCH 123/506] fix(telegram): split long messages to avoid Message
is too long error
Telegram has a 4096 character limit per message. This fix:
- Splits messages longer than 4000 chars into multiple chunks
- Prefers breaking at newline boundaries to preserve formatting
- Falls back to space boundaries if no newlines available
- Forces split at max length if no good boundaries exist
- Adds comprehensive tests for message splitting logic
---
.gitignore | 2 +-
nanobot/channels/telegram.py | 57 +++--
tests/test_telegram_channel.py | 416 +++++++++++++++++++++++++++++++++
3 files changed, 459 insertions(+), 16 deletions(-)
create mode 100644 tests/test_telegram_channel.py
diff --git a/.gitignore b/.gitignore
index d7b930d..742d593 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,4 +19,4 @@ __pycache__/
poetry.lock
.pytest_cache/
botpy.log
-tests/
+
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 32f8c67..a45178b 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -188,27 +188,54 @@ class TelegramChannel(BaseChannel):
self._stop_typing(msg.chat_id)
try:
- # chat_id should be the Telegram chat ID (integer)
chat_id = int(msg.chat_id)
- # Convert markdown to Telegram HTML
- html_content = _markdown_to_telegram_html(msg.content)
- await self._app.bot.send_message(
- chat_id=chat_id,
- text=html_content,
- parse_mode="HTML"
- )
except ValueError:
logger.error(f"Invalid chat_id: {msg.chat_id}")
- except Exception as e:
- # Fallback to plain text if HTML parsing fails
- logger.warning(f"HTML parse failed, falling back to plain text: {e}")
+ return
+
+ # Split content into chunks (Telegram limit: 4096 chars)
+ MAX_LENGTH = 4000 # Leave some margin for safety
+ content = msg.content
+ chunks = []
+
+ while content:
+ if len(content) <= MAX_LENGTH:
+ chunks.append(content)
+ break
+
+ # Find a good break point (newline or space)
+ chunk = content[:MAX_LENGTH]
+ # Prefer breaking at newline
+ break_pos = chunk.rfind('\n')
+ if break_pos == -1:
+ # Fall back to last space
+ break_pos = chunk.rfind(' ')
+ if break_pos == -1:
+ # No good break point, force break at limit
+ break_pos = MAX_LENGTH
+
+ chunks.append(content[:break_pos])
+ content = content[break_pos:].lstrip()
+
+ # Send each chunk
+ for i, chunk in enumerate(chunks):
try:
+ html_content = _markdown_to_telegram_html(chunk)
await self._app.bot.send_message(
- chat_id=int(msg.chat_id),
- text=msg.content
+ chat_id=chat_id,
+ text=html_content,
+ parse_mode="HTML"
)
- except Exception as e2:
- logger.error(f"Error sending Telegram message: {e2}")
+ except Exception as e:
+ # Fallback to plain text if HTML parsing fails
+ logger.warning(f"HTML parse failed for chunk {i+1}, falling back to plain text: {e}")
+ try:
+ await self._app.bot.send_message(
+ chat_id=chat_id,
+ text=chunk
+ )
+ except Exception as e2:
+ logger.error(f"Error sending Telegram chunk {i+1}: {e2}")
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command."""
diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py
new file mode 100644
index 0000000..8e9a4d4
--- /dev/null
+++ b/tests/test_telegram_channel.py
@@ -0,0 +1,416 @@
+"""Tests for Telegram channel implementation."""
+
+import pytest
+from unittest.mock import AsyncMock, MagicMock
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.telegram import TelegramChannel, _markdown_to_telegram_html
+from nanobot.config.schema import TelegramConfig
+
+
+def _make_config() -> TelegramConfig:
+ return TelegramConfig(
+ enabled=True,
+ token="fake-token",
+ allow_from=[],
+ proxy=None,
+ )
+
+
+class TestMarkdownToTelegramHtml:
+ """Tests for markdown to Telegram HTML conversion."""
+
+ def test_empty_text(self) -> None:
+ assert _markdown_to_telegram_html("") == ""
+
+ def test_plain_text_passthrough(self) -> None:
+ text = "Hello world"
+ assert _markdown_to_telegram_html(text) == "Hello world"
+
+ def test_bold_double_asterisks(self) -> None:
+ text = "This is **bold** text"
+ assert _markdown_to_telegram_html(text) == "This is bold text"
+
+ def test_bold_double_underscore(self) -> None:
+ text = "This is __bold__ text"
+ assert _markdown_to_telegram_html(text) == "This is bold text"
+
+ def test_italic_underscore(self) -> None:
+ text = "This is _italic_ text"
+ assert _markdown_to_telegram_html(text) == "This is italic text"
+
+ def test_italic_not_inside_words(self) -> None:
+ text = "some_var_name"
+ assert _markdown_to_telegram_html(text) == "some_var_name"
+
+ def test_strikethrough(self) -> None:
+ text = "This is ~~deleted~~ text"
+ assert _markdown_to_telegram_html(text) == "This is deleted text"
+
+ def test_inline_code(self) -> None:
+ text = "Use `print()` function"
+ result = _markdown_to_telegram_html(text)
+ assert "print()" in result
+
+ def test_inline_code_escapes_html(self) -> None:
+ text = "Use `` tag"
+ result = _markdown_to_telegram_html(text)
+ assert "
<div>" in result
+
+ def test_code_block(self) -> None:
+ text = """Here is code:
+```python
+def hello():
+ return "world"
+```
+Done.
+"""
+ result = _markdown_to_telegram_html(text)
+ assert "
" in result
+ assert "def hello():" in result
+ assert "
" in result
+
+ def test_code_block_escapes_html(self) -> None:
+ text = """```
+
test
+```"""
+ result = _markdown_to_telegram_html(text)
+ assert "<div>test</div>" in result
+
+ def test_headers_stripped(self) -> None:
+ text = "# Header 1\n## Header 2\n### Header 3"
+ result = _markdown_to_telegram_html(text)
+ assert "# Header 1" not in result
+ assert "Header 1" in result
+ assert "Header 2" in result
+ assert "Header 3" in result
+
+ def test_blockquotes_stripped(self) -> None:
+ text = "> This is a quote\nMore text"
+ result = _markdown_to_telegram_html(text)
+ assert "> " not in result
+ assert "This is a quote" in result
+
+ def test_links_converted(self) -> None:
+ text = "Check [this link](https://example.com) out"
+ result = _markdown_to_telegram_html(text)
+ assert '
this link' in result
+
+ def test_bullet_list_converted(self) -> None:
+ text = "- Item 1\n* Item 2"
+ result = _markdown_to_telegram_html(text)
+ assert "• Item 1" in result
+ assert "• Item 2" in result
+
+ def test_html_special_chars_escaped(self) -> None:
+ text = "5 < 10 and 10 > 5"
+ result = _markdown_to_telegram_html(text)
+ assert "5 < 10" in result
+ assert "10 > 5" in result
+
+ def test_complex_nested_formatting(self) -> None:
+ text = "**Bold _and italic_** and `code`"
+ result = _markdown_to_telegram_html(text)
+ assert "
Bold and italic" in result
+ assert "
code" in result
+
+
+class TestTelegramChannelSend:
+ """Tests for TelegramChannel.send() method."""
+
+ @pytest.mark.asyncio
+ async def test_send_short_message_single_chunk(self, monkeypatch) -> None:
+ """Short messages are sent as a single message."""
+ sent_messages = []
+
+ class FakeBot:
+ async def send_message(self, chat_id, text, parse_mode=None):
+ sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
+
+ fake_app = MagicMock()
+ fake_app.bot = FakeBot()
+
+ channel = TelegramChannel(_make_config(), MessageBus())
+ channel._app = fake_app
+
+ await channel.send(OutboundMessage(
+ channel="telegram",
+ chat_id="123456",
+ content="Hello world"
+ ))
+
+ assert len(sent_messages) == 1
+ assert sent_messages[0]["chat_id"] == 123456
+ assert "Hello world" in sent_messages[0]["text"]
+ assert sent_messages[0]["parse_mode"] == "HTML"
+
+ @pytest.mark.asyncio
+ async def test_send_long_message_split_into_chunks(self, monkeypatch) -> None:
+ """Long messages exceeding 4000 chars are split."""
+ sent_messages = []
+
+ class FakeBot:
+ async def send_message(self, chat_id, text, parse_mode=None):
+ sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
+
+ fake_app = MagicMock()
+ fake_app.bot = FakeBot()
+
+ channel = TelegramChannel(_make_config(), MessageBus())
+ channel._app = fake_app
+
+ # Create a message longer than 4000 chars
+ long_content = "A" * 1000 + "\n" + "B" * 1000 + "\n" + "C" * 1000 + "\n" + "D" * 1000 + "\n" + "E" * 1000
+
+ await channel.send(OutboundMessage(
+ channel="telegram",
+ chat_id="123456",
+ content=long_content
+ ))
+
+ assert len(sent_messages) == 2 # Should be split into 2 messages
+ assert all(m["chat_id"] == 123456 for m in sent_messages)
+
+ @pytest.mark.asyncio
+ async def test_send_splits_at_newline_when_possible(self, monkeypatch) -> None:
+ """Message splitting prefers newline boundaries."""
+ sent_messages = []
+
+ class FakeBot:
+ async def send_message(self, chat_id, text, parse_mode=None):
+ sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
+
+ fake_app = MagicMock()
+ fake_app.bot = FakeBot()
+
+ channel = TelegramChannel(_make_config(), MessageBus())
+ channel._app = fake_app
+
+ # Create content with clear paragraph breaks
+ paragraphs = [f"Paragraph {i}: " + "x" * 100 for i in range(50)]
+ content = "\n".join(paragraphs)
+
+ await channel.send(OutboundMessage(
+ channel="telegram",
+ chat_id="123456",
+ content=content
+ ))
+
+ # Each chunk should end with a complete paragraph (no partial lines)
+ for msg in sent_messages:
+ # Message should not start with whitespace after stripping
+ text = msg["text"]
+ assert text == text.lstrip()
+
+ @pytest.mark.asyncio
+ async def test_send_falls_back_to_space_boundary(self, monkeypatch) -> None:
+ """When no newline available, split at space boundary."""
+ sent_messages = []
+
+ class FakeBot:
+ async def send_message(self, chat_id, text, parse_mode=None):
+ sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
+
+ fake_app = MagicMock()
+ fake_app.bot = FakeBot()
+
+ channel = TelegramChannel(_make_config(), MessageBus())
+ channel._app = fake_app
+
+ # Long content without newlines but with spaces
+ content = "word " * 2000 # ~10000 chars
+
+ await channel.send(OutboundMessage(
+ channel="telegram",
+ chat_id="123456",
+ content=content
+ ))
+
+ assert len(sent_messages) >= 2
+
+ @pytest.mark.asyncio
+ async def test_send_forces_split_when_no_good_boundary(self, monkeypatch) -> None:
+ """When no newline or space, force split at max length."""
+ sent_messages = []
+
+ class FakeBot:
+ async def send_message(self, chat_id, text, parse_mode=None):
+ sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
+
+ fake_app = MagicMock()
+ fake_app.bot = FakeBot()
+
+ channel = TelegramChannel(_make_config(), MessageBus())
+ channel._app = fake_app
+
+ # Long content without any spaces or newlines
+ content = "A" * 10000
+
+ await channel.send(OutboundMessage(
+ channel="telegram",
+ chat_id="123456",
+ content=content
+ ))
+
+ assert len(sent_messages) >= 2
+ # Verify all chunks combined equal original
+ combined = "".join(m["text"] for m in sent_messages)
+ assert combined == content
+
+ @pytest.mark.asyncio
+ async def test_send_invalid_chat_id_logs_error(self, monkeypatch) -> None:
+ """Invalid chat_id should log error and not send."""
+ sent_messages = []
+
+ class FakeBot:
+ async def send_message(self, chat_id, text, parse_mode=None):
+ sent_messages.append({"chat_id": chat_id, "text": text})
+
+ fake_app = MagicMock()
+ fake_app.bot = FakeBot()
+
+ channel = TelegramChannel(_make_config(), MessageBus())
+ channel._app = fake_app
+
+ await channel.send(OutboundMessage(
+ channel="telegram",
+ chat_id="not-a-number",
+ content="Hello"
+ ))
+
+ assert len(sent_messages) == 0
+
+ @pytest.mark.asyncio
+ async def test_send_html_parse_error_falls_back_to_plain_text(self, monkeypatch) -> None:
+ """When HTML parsing fails, fall back to plain text."""
+ sent_messages = []
+ call_count = 0
+
+ class FakeBot:
+ async def send_message(self, chat_id, text, parse_mode=None):
+ nonlocal call_count
+ call_count += 1
+ if parse_mode == "HTML" and call_count == 1:
+ raise Exception("Bad markup")
+ sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
+
+ fake_app = MagicMock()
+ fake_app.bot = FakeBot()
+
+ channel = TelegramChannel(_make_config(), MessageBus())
+ channel._app = fake_app
+
+ await channel.send(OutboundMessage(
+ channel="telegram",
+ chat_id="123456",
+ content="Hello **world**"
+ ))
+
+ # Should have 2 calls: first HTML (fails), second plain text (succeeds)
+ assert call_count == 2
+ assert len(sent_messages) == 1
+ assert sent_messages[0]["parse_mode"] is None # Plain text
+ assert "Hello **world**" in sent_messages[0]["text"]
+
+ @pytest.mark.asyncio
+ async def test_send_not_running_warns(self, monkeypatch) -> None:
+ """If bot not running, log warning."""
+ warning_logged = []
+
+ def mock_warning(msg, *args):
+ warning_logged.append(msg)
+
+ monkeypatch.setattr("nanobot.channels.telegram.logger", MagicMock(warning=mock_warning))
+
+ channel = TelegramChannel(_make_config(), MessageBus())
+ channel._app = None # Not running
+
+ await channel.send(OutboundMessage(
+ channel="telegram",
+ chat_id="123456",
+ content="Hello"
+ ))
+
+ assert any("not running" in str(m) for m in warning_logged)
+
+ @pytest.mark.asyncio
+ async def test_send_stops_typing_indicator(self, monkeypatch) -> None:
+ """Sending message should stop typing indicator."""
+ stopped_chats = []
+
+ class FakeBot:
+ async def send_message(self, chat_id, text, parse_mode=None):
+ pass
+
+ fake_app = MagicMock()
+ fake_app.bot = FakeBot()
+
+ channel = TelegramChannel(_make_config(), MessageBus())
+ channel._app = fake_app
+ channel._stop_typing = lambda chat_id: stopped_chats.append(chat_id)
+
+ await channel.send(OutboundMessage(
+ channel="telegram",
+ chat_id="123456",
+ content="Hello"
+ ))
+
+ assert "123456" in stopped_chats
+
+
+class TestTelegramChannelTyping:
+ """Tests for typing indicator functionality."""
+
+ @pytest.mark.asyncio
+ async def test_start_typing_creates_task(self) -> None:
+ channel = TelegramChannel(_make_config(), MessageBus())
+
+ # Mock _typing_loop to avoid actual async execution
+ channel._typing_loop = AsyncMock()
+
+ channel._start_typing("123456")
+
+ assert "123456" in channel._typing_tasks
+ assert not channel._typing_tasks["123456"].done()
+
+ # Clean up
+ channel._stop_typing("123456")
+
+ def test_stop_typing_cancels_task(self) -> None:
+ channel = TelegramChannel(_make_config(), MessageBus())
+
+ # Create a mock task
+ mock_task = MagicMock()
+ mock_task.done.return_value = False
+ channel._typing_tasks["123456"] = mock_task
+
+ channel._stop_typing("123456")
+
+ mock_task.cancel.assert_called_once()
+ assert "123456" not in channel._typing_tasks
+
+
+class TestTelegramChannelMediaExtensions:
+ """Tests for media file extension detection."""
+
+ def test_get_extension_from_mime_type(self) -> None:
+ channel = TelegramChannel(_make_config(), MessageBus())
+
+ assert channel._get_extension("image", "image/jpeg") == ".jpg"
+ assert channel._get_extension("image", "image/png") == ".png"
+ assert channel._get_extension("image", "image/gif") == ".gif"
+ assert channel._get_extension("audio", "audio/ogg") == ".ogg"
+ assert channel._get_extension("audio", "audio/mpeg") == ".mp3"
+
+ def test_get_extension_fallback_to_type(self) -> None:
+ channel = TelegramChannel(_make_config(), MessageBus())
+
+ assert channel._get_extension("image", None) == ".jpg"
+ assert channel._get_extension("voice", None) == ".ogg"
+ assert channel._get_extension("audio", None) == ".mp3"
+
+ def test_get_extension_unknown_type(self) -> None:
+ channel = TelegramChannel(_make_config(), MessageBus())
+
+ assert channel._get_extension("unknown", None) == ""
From 9bfc86af41144a2cc74ac0c5390156e29302cb26 Mon Sep 17 00:00:00 2001
From: zhouzhuojie
Date: Sun, 15 Feb 2026 22:49:01 +0000
Subject: [PATCH 124/506] refactor(telegram): extract message splitting into
helper function
- Added _split_message() helper for cleaner separation of concerns
- Simplified send() method by using the helper
- Net -18 lines for the message splitting feature
---
nanobot/channels/telegram.py | 68 +++++++++++++-----------------------
1 file changed, 25 insertions(+), 43 deletions(-)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index a45178b..09467af 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -78,6 +78,23 @@ def _markdown_to_telegram_html(text: str) -> str:
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 = []
+ while len(content) > max_len:
+ chunk = content[:max_len]
+ break_pos = chunk.rfind('\n')
+ if break_pos == -1:
+ break_pos = chunk.rfind(' ')
+ if break_pos == -1:
+ break_pos = max_len
+ chunks.append(chunk[:break_pos])
+ content = content[break_pos:].lstrip()
+ return chunks + [content]
+
+
class TelegramChannel(BaseChannel):
"""
Telegram channel using long polling.
@@ -183,59 +200,24 @@ class TelegramChannel(BaseChannel):
if not self._app:
logger.warning("Telegram bot not running")
return
-
- # Stop typing indicator for this chat
+
self._stop_typing(msg.chat_id)
-
+
try:
chat_id = int(msg.chat_id)
except ValueError:
logger.error(f"Invalid chat_id: {msg.chat_id}")
return
-
- # Split content into chunks (Telegram limit: 4096 chars)
- MAX_LENGTH = 4000 # Leave some margin for safety
- content = msg.content
- chunks = []
-
- while content:
- if len(content) <= MAX_LENGTH:
- chunks.append(content)
- break
-
- # Find a good break point (newline or space)
- chunk = content[:MAX_LENGTH]
- # Prefer breaking at newline
- break_pos = chunk.rfind('\n')
- if break_pos == -1:
- # Fall back to last space
- break_pos = chunk.rfind(' ')
- if break_pos == -1:
- # No good break point, force break at limit
- break_pos = MAX_LENGTH
-
- chunks.append(content[:break_pos])
- content = content[break_pos:].lstrip()
-
- # Send each chunk
- for i, chunk in enumerate(chunks):
+
+ for chunk in _split_message(msg.content):
try:
- html_content = _markdown_to_telegram_html(chunk)
- await self._app.bot.send_message(
- chat_id=chat_id,
- text=html_content,
- parse_mode="HTML"
- )
+ await self._app.bot.send_message(chat_id=chat_id, text=_markdown_to_telegram_html(chunk), parse_mode="HTML")
except Exception as e:
- # Fallback to plain text if HTML parsing fails
- logger.warning(f"HTML parse failed for chunk {i+1}, falling back to plain text: {e}")
+ logger.warning(f"HTML parse failed, falling back to plain text: {e}")
try:
- await self._app.bot.send_message(
- chat_id=chat_id,
- text=chunk
- )
+ await self._app.bot.send_message(chat_id=chat_id, text=chunk)
except Exception as e2:
- logger.error(f"Error sending Telegram chunk {i+1}: {e2}")
+ logger.error(f"Error sending Telegram message: {e2}")
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command."""
From 51d22b7ef46cfcd188068422cbc6bb23937382d9 Mon Sep 17 00:00:00 2001
From: Thomas Lisankie
Date: Mon, 16 Feb 2026 00:14:34 -0500
Subject: [PATCH 125/506] Fix: _forward_command now builds sender_id with
username for allowlist matching
---
nanobot/channels/telegram.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 32f8c67..efd009e 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -226,8 +226,14 @@ class TelegramChannel(BaseChannel):
"""Forward slash commands to the bus for unified handling in AgentLoop."""
if not update.message or not update.effective_user:
return
+
+ user = update.effective_user
+ sender_id = str(user.id)
+ if user.username:
+ sender_id = f"{sender_id}|{user.username}"
+
await self._handle_message(
- sender_id=str(update.effective_user.id),
+ sender_id=sender_id,
chat_id=str(update.message.chat_id),
content=update.message.text,
)
From 90be9004487f74060bcb7f8a605a3656c5b4dfdf Mon Sep 17 00:00:00 2001
From: "Aleksander W. Oleszkiewicz (Alek)"
<24917047+alekwo@users.noreply.github.com>
Date: Mon, 16 Feb 2026 09:49:44 +0100
Subject: [PATCH 126/506] Enhance Slack message formatting with new regex rules
Added regex substitutions for strikethrough, URL formatting, and image URLs in Slack message conversion.
---
nanobot/channels/slack.py | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index 7298435..6b685d1 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -223,9 +223,21 @@ class SlackChannel(BaseChannel):
# Step 3.a: ***text*** -> *_text_*
converted_text = re.sub(
r"(?m)(^|[^\*])\*\*\*([^\*].+?[^\*])\*\*\*([^\*]|$)", r"\1*_\2_*\3", converted_text)
- # Step 3.b - ___text___ to *_text_*
+ # Step 3.b - ___text___ -> *_text_*
converted_text = re.sub(
r"(?m)(^|[^_])___([^_].+?[^_])___([^_]|$)", r"\1*_\2_*\3", converted_text)
+ # Convert strikethrough
+ # Step 4: ~~text~~ -> ~text~
+ converted_text = re.sub(
+ r"(?m)(^|[^~])~~([^~].+?[^~])~~([^~]|$)", r"\1~\2~\3", converted_text)
+ # Convert URL formatting
+ # Step 6: [text](URL) ->
+ converted_text = re.sub(
+ r"(^|[^!])\[(.+?)\]\((http.+?)\)", r"<\2|\1>", converted_text)
+ # Convert image URL
+ # Step 6:  ->
+ converted_text = re.sub(
+ r"[!]\[.+?\]\((http.+?)(?: \".*?\")?\)", r"<\2>", converted_text)
return converted_text
def escape_mrkdwn(text: str) -> str:
return (text.replace('&', '&')
From 5d683da38f577a66a3ae7f1ceacbcdffcb4c56df Mon Sep 17 00:00:00 2001
From: "Aleksander W. Oleszkiewicz (Alek)"
<24917047+alekwo@users.noreply.github.com>
Date: Mon, 16 Feb 2026 09:53:20 +0100
Subject: [PATCH 127/506] Fix regex for URL and image URL formatting
---
nanobot/channels/slack.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index 6b685d1..ef78887 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -233,11 +233,11 @@ class SlackChannel(BaseChannel):
# Convert URL formatting
# Step 6: [text](URL) ->
converted_text = re.sub(
- r"(^|[^!])\[(.+?)\]\((http.+?)\)", r"<\2|\1>", converted_text)
+ r"(^|[^!])\[(.+?)\]\((http.+?)\)", r"\1<\3|\2>", converted_text)
# Convert image URL
# Step 6:  ->
converted_text = re.sub(
- r"[!]\[.+?\]\((http.+?)(?: \".*?\")?\)", r"<\2>", converted_text)
+ r"[!]\[.+?\]\((http.+?)(?: \".*?\")?\)", r"<\1>", converted_text)
return converted_text
def escape_mrkdwn(text: str) -> str:
return (text.replace('&', '&')
From fe0341da5ba862bd206821467118f29cec9640d9 Mon Sep 17 00:00:00 2001
From: "Aleksander W. Oleszkiewicz (Alek)"
<24917047+alekwo@users.noreply.github.com>
Date: Mon, 16 Feb 2026 09:58:38 +0100
Subject: [PATCH 128/506] Fix regex for URL formatting in Slack channel
---
nanobot/channels/slack.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index ef78887..25af83b 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -233,7 +233,7 @@ class SlackChannel(BaseChannel):
# Convert URL formatting
# Step 6: [text](URL) ->
converted_text = re.sub(
- r"(^|[^!])\[(.+?)\]\((http.+?)\)", r"\1<\3|\2>", converted_text)
+ r"(?m)(^|[^!])\[(.+?)\]\((http.+?)\)", r"\1<\3|\2>", converted_text)
# Convert image URL
# Step 6:  ->
converted_text = re.sub(
From 1ce586e9f515ca537353331f726307844e1b4e2f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 16 Feb 2026 11:43:36 +0000
Subject: [PATCH 129/506] fix: resolve Codex provider bugs and simplify
implementation
---
README.md | 31 ++++++++++
nanobot/cli/commands.py | 5 +-
nanobot/config/schema.py | 10 ++--
nanobot/providers/openai_codex_provider.py | 69 +++++++++-------------
nanobot/providers/registry.py | 2 -
5 files changed, 65 insertions(+), 52 deletions(-)
diff --git a/README.md b/README.md
index f6362bb..6a3ec3e 100644
--- a/README.md
+++ b/README.md
@@ -585,6 +585,37 @@ Config file: `~/.nanobot/config.json`
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
+| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
+
+
+OpenAI Codex (OAuth)
+
+Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account.
+
+**1. Login:**
+```bash
+nanobot provider login openai-codex
+```
+
+**2. Set model** (merge into `~/.nanobot/config.json`):
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "openai-codex/gpt-5.1-codex"
+ }
+ }
+}
+```
+
+**3. Chat:**
+```bash
+nanobot agent -m "Hello!"
+```
+
+> Docker users: use `docker run -it` for interactive OAuth login.
+
+
Custom Provider (Any OpenAI-compatible API)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index b2d3f5a..235bfdc 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -290,10 +290,7 @@ def _make_provider(config: Config):
# OpenAI Codex (OAuth): don't route via LiteLLM; use the dedicated implementation.
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
- return OpenAICodexProvider(
- default_model=model,
- api_base=p.api_base if p else None,
- )
+ return OpenAICodexProvider(default_model=model)
if not model.startswith("bedrock/") and not (p and p.api_key):
console.print("[red]Error: No API key configured.[/red]")
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 9d648be..15b6bb2 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -192,7 +192,7 @@ class ProvidersConfig(BaseModel):
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
- openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) # AiHubMix API gateway
+ openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
class GatewayConfig(BaseModel):
@@ -252,19 +252,19 @@ class Config(BaseSettings):
model_lower = (model or self.agents.defaults.model).lower()
# Match by keyword (order follows PROVIDERS registry)
- # Note: OAuth providers don't require api_key, so we check is_oauth flag
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
if p and any(kw in model_lower for kw in spec.keywords):
- # OAuth providers don't need api_key
if spec.is_oauth or p.api_key:
return p, spec.name
# Fallback: gateways first, then others (follows registry order)
- # OAuth providers are also valid fallbacks
+ # OAuth providers are NOT valid fallbacks — they require explicit model selection
for spec in PROVIDERS:
+ if spec.is_oauth:
+ continue
p = getattr(self.providers, spec.name, None)
- if p and (spec.is_oauth or p.api_key):
+ if p and p.api_key:
return p, spec.name
return None, None
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index f6d56aa..5067438 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -8,6 +8,7 @@ import json
from typing import Any, AsyncGenerator
import httpx
+from loguru import logger
from oauth_cli_kit import get_token as get_codex_token
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
@@ -59,9 +60,9 @@ class OpenAICodexProvider(LLMProvider):
try:
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=True)
except Exception as e:
- # Certificate verification failed, downgrade to disable verification (security risk)
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
raise
+ logger.warning("SSL certificate verification failed for Codex API; retrying with verify=False")
content, tool_calls, finish_reason = await _request_codex(url, headers, body, verify=False)
return LLMResponse(
content=content,
@@ -77,6 +78,7 @@ class OpenAICodexProvider(LLMProvider):
def get_default_model(self) -> str:
return self.default_model
+
def _strip_model_prefix(model: str) -> str:
if model.startswith("openai-codex/"):
return model.split("/", 1)[1]
@@ -94,6 +96,7 @@ def _build_headers(account_id: str, token: str) -> dict[str, str]:
"content-type": "application/json",
}
+
async def _request_codex(
url: str,
headers: dict[str, str],
@@ -107,36 +110,25 @@ async def _request_codex(
raise RuntimeError(_friendly_error(response.status_code, text.decode("utf-8", "ignore")))
return await _consume_sse(response)
+
def _convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
- # Nanobot tool definitions already use the OpenAI function schema.
+ """Convert OpenAI function-calling schema to Codex flat format."""
converted: list[dict[str, Any]] = []
for tool in tools:
- fn = tool.get("function") if isinstance(tool, dict) and tool.get("type") == "function" else None
- if fn and isinstance(fn, dict):
- name = fn.get("name")
- desc = fn.get("description")
- params = fn.get("parameters")
- else:
- name = tool.get("name")
- desc = tool.get("description")
- params = tool.get("parameters")
- if not isinstance(name, str) or not name:
- # Skip invalid tools to avoid Codex rejection.
+ fn = (tool.get("function") or {}) if tool.get("type") == "function" else tool
+ name = fn.get("name")
+ if not name:
continue
- params = params or {}
- if not isinstance(params, dict):
- # Parameters must be a JSON Schema object.
- params = {}
- converted.append(
- {
- "type": "function",
- "name": name,
- "description": desc or "",
- "parameters": params,
- }
- )
+ params = fn.get("parameters") or {}
+ converted.append({
+ "type": "function",
+ "name": name,
+ "description": fn.get("description") or "",
+ "parameters": params if isinstance(params, dict) else {},
+ })
return converted
+
def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
system_prompt = ""
input_items: list[dict[str, Any]] = []
@@ -183,7 +175,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
continue
if role == "tool":
- call_id = _extract_call_id(msg.get("tool_call_id"))
+ call_id, _ = _split_tool_call_id(msg.get("tool_call_id"))
output_text = content if isinstance(content, str) else json.dumps(content)
input_items.append(
{
@@ -196,6 +188,7 @@ def _convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[st
return system_prompt, input_items
+
def _convert_user_message(content: Any) -> dict[str, Any]:
if isinstance(content, str):
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
@@ -215,12 +208,6 @@ def _convert_user_message(content: Any) -> dict[str, Any]:
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
-def _extract_call_id(tool_call_id: Any) -> str:
- if isinstance(tool_call_id, str) and tool_call_id:
- return tool_call_id.split("|", 1)[0]
- return "call_0"
-
-
def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
if isinstance(tool_call_id, str) and tool_call_id:
if "|" in tool_call_id:
@@ -229,10 +216,12 @@ def _split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
return tool_call_id, None
return "call_0", None
+
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
raw = json.dumps(messages, ensure_ascii=True, sort_keys=True)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
buffer: list[str] = []
async for line in response.aiter_lines():
@@ -252,6 +241,7 @@ async def _iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any],
continue
buffer.append(line)
+
async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequest], str]:
content = ""
tool_calls: list[ToolCallRequest] = []
@@ -308,16 +298,13 @@ async def _consume_sse(response: httpx.Response) -> tuple[str, list[ToolCallRequ
return content, tool_calls, finish_reason
+
+_FINISH_REASON_MAP = {"completed": "stop", "incomplete": "length", "failed": "error", "cancelled": "error"}
+
+
def _map_finish_reason(status: str | None) -> str:
- if not status:
- return "stop"
- if status == "completed":
- return "stop"
- if status == "incomplete":
- return "length"
- if status in {"failed", "cancelled"}:
- return "error"
- return "stop"
+ return _FINISH_REASON_MAP.get(status or "completed", "stop")
+
def _friendly_error(status_code: int, raw: str) -> str:
if status_code == 429:
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 1b4a776..59af5e1 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -53,7 +53,6 @@ class ProviderSpec:
# OAuth-based providers (e.g., OpenAI Codex) don't use API keys
is_oauth: bool = False # if True, uses OAuth flow instead of API key
- oauth_provider: str = "" # OAuth provider name for token retrieval
@property
def label(self) -> str:
@@ -176,7 +175,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
strip_model_prefix=False,
model_overrides=(),
is_oauth=True, # OAuth-based authentication
- oauth_provider="openai-codex", # OAuth provider identifier
),
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
From e8e7215d3ea2a99ec36859603a40c06b8fec26a9 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 16 Feb 2026 11:57:55 +0000
Subject: [PATCH 130/506] refactor: simplify Slack markdown-to-mrkdwn
conversion
---
nanobot/channels/slack.py | 154 +++++++++-----------------------------
1 file changed, 37 insertions(+), 117 deletions(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index 25af83b..dd18e79 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -204,126 +204,46 @@ class SlackChannel(BaseChannel):
return text
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
+ # Markdown → Slack mrkdwn formatting rules (order matters: longest markers first)
+ _MD_TO_SLACK = (
+ (r'(?m)(^|[^\*])\*\*\*(.+?)\*\*\*([^\*]|$)', r'\1*_\2_*\3'), # ***bold italic***
+ (r'(?m)(^|[^_])___(.+?)___([^_]|$)', r'\1*_\2_*\3'), # ___bold italic___
+ (r'(?m)(^|[^\*])\*\*(.+?)\*\*([^\*]|$)', r'\1*\2*\3'), # **bold**
+ (r'(?m)(^|[^_])__(.+?)__([^_]|$)', r'\1*\2*\3'), # __bold__
+ (r'(?m)(^|[^\*])\*(.+?)\*([^\*]|$)', r'\1_\2_\3'), # *italic*
+ (r'(?m)(^|[^~])~~(.+?)~~([^~]|$)', r'\1~\2~\3'), # ~~strike~~
+ (r'(?m)(^|[^!])\[(.+?)\]\((http.+?)\)', r'\1<\3|\2>'), # [text](url)
+ (r'!\[.+?\]\((http.+?)(?:\s".*?")?\)', r'<\1>'), # 
+ )
+ _TABLE_RE = re.compile(r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*')
+
def _convert_markdown(self, text: str) -> str:
+ """Convert standard Markdown to Slack mrkdwn format."""
if not text:
return text
- def convert_formatting(input: str) -> str:
- # Convert italics
- # Step 1: *text* -> _text_
- converted_text = re.sub(
- r"(?m)(^|[^\*])\*([^\*].+?[^\*])\*([^\*]|$)", r"\1_\2_\3", input)
- # Convert bold
- # Step 2.a: **text** -> *text*
- converted_text = re.sub(
- r"(?m)(^|[^\*])\*\*([^\*].+?[^\*])\*\*([^\*]|$)", r"\1*\2*\3", converted_text)
- # Step 2.b: __text__ -> *text*
- converted_text = re.sub(
- r"(?m)(^|[^_])__([^_].+?[^_])__([^_]|$)", r"\1*\2*\3", converted_text)
- # convert bold italics
- # Step 3.a: ***text*** -> *_text_*
- converted_text = re.sub(
- r"(?m)(^|[^\*])\*\*\*([^\*].+?[^\*])\*\*\*([^\*]|$)", r"\1*_\2_*\3", converted_text)
- # Step 3.b - ___text___ -> *_text_*
- converted_text = re.sub(
- r"(?m)(^|[^_])___([^_].+?[^_])___([^_]|$)", r"\1*_\2_*\3", converted_text)
- # Convert strikethrough
- # Step 4: ~~text~~ -> ~text~
- converted_text = re.sub(
- r"(?m)(^|[^~])~~([^~].+?[^~])~~([^~]|$)", r"\1~\2~\3", converted_text)
- # Convert URL formatting
- # Step 6: [text](URL) ->
- converted_text = re.sub(
- r"(?m)(^|[^!])\[(.+?)\]\((http.+?)\)", r"\1<\3|\2>", converted_text)
- # Convert image URL
- # Step 6:  ->
- converted_text = re.sub(
- r"[!]\[.+?\]\((http.+?)(?: \".*?\")?\)", r"<\1>", converted_text)
- return converted_text
- def escape_mrkdwn(text: str) -> str:
- return (text.replace('&', '&')
- .replace('<', '<')
- .replace('>', '>'))
- def convert_table(match: re.Match) -> str:
- # Slack doesn't support Markdown tables
- # Convert table to bulleted list with sections
- # -- input_md:
- # Some text before the table.
- # | Col1 | Col2 | Col3 |
- # |-----|----------|------|
- # | Row1 - A | Row1 - B | Row1 - C |
- # | Row2 - D | Row2 - E | Row2 - F |
- #
- # Some text after the table.
- #
- # -- will be converted to:
- # Some text before the table.
- # > *Col1* : Row1 - A
- # • *Col2*: Row1 - B
- # • *Col3*: Row1 - C
- # > *Col1* : Row2 - D
- # • *Col2*: Row2 - E
- # • *Col3*: Row2 - F
- #
- # Some text after the table.
-
- block = match.group(0).strip()
- lines = [line.strip()
- for line in block.split('\n') if line.strip()]
+ for pattern, repl in self._MD_TO_SLACK:
+ text = re.sub(pattern, repl, text)
+ return self._TABLE_RE.sub(self._convert_table, text)
- if len(lines) < 2:
- return block
+ @staticmethod
+ def _convert_table(match: re.Match) -> str:
+ """Convert Markdown table to Slack quote + bullet format."""
+ lines = [l.strip() for l in match.group(0).strip().split('\n') if l.strip()]
+ if len(lines) < 2:
+ return match.group(0)
- # 1. Parse Headers from the first line
- # Split by pipe, filtering out empty start/end strings caused by outer pipes
- header_line = lines[0].strip('|')
- headers = [escape_mrkdwn(h.strip())
- for h in header_line.split('|')]
+ headers = [h.strip() for h in lines[0].strip('|').split('|')]
+ start = 2 if not re.search(r'[^|\-\s:]', lines[1]) else 1
- # 2. Identify Data Start (Skip Separator)
- data_start_idx = 1
- # If line 2 contains only separator chars (|-: ), skip it
- if len(lines) > 1 and not re.search(r'[^|\-\s:]', lines[1]):
- data_start_idx = 2
-
- # 3. Process Data Rows
- slack_lines = []
- for line in lines[data_start_idx:]:
- # Clean and split cells
- clean_line = line.strip('|')
- cells = [escape_mrkdwn(c.strip())
- for c in clean_line.split('|')]
-
- # Normalize cell count to match headers
- if len(cells) < len(headers):
- cells += [''] * (len(headers) - len(cells))
- cells = cells[:len(headers)]
-
- # Skip empty rows
- if not any(cells):
- continue
-
- # Key is the first column
- key = cells[0]
- label = headers[0]
- slack_lines.append(
- f"> *{label}* : {key}" if key else "> *{label}* : --")
-
- # Sub-bullets for remaining columns
- for i, cell in enumerate(cells[1:], 1):
- if cell:
- label = headers[i] if i < len(headers) else "Col"
- slack_lines.append(f" • *{label}*: {cell}")
-
- slack_lines.append("") # Spacer between items
-
- return "\n".join(slack_lines).rstrip()
-
- # (?m) : Multiline mode so ^ matches start of line and $ end of line
- # ^\| : Start of line and a literal pipe
- # .*?\|$ : Rest of the line and a pipe at the end
- # (?:\n(?:\|\:?-{3,}\:?)*?\|$) : A heading line with at least three dashes in each column, pipes, and : e.g. |:---|----|:---:|
- # (?:\n\|.*?\|$)* : Zero or more subsequent lines that ALSO start and end with a pipe
- table_pattern = r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*'
-
- input_md = convert_formatting(text)
- return re.sub(table_pattern, convert_table, input_md)
+ result: list[str] = []
+ for line in lines[start:]:
+ cells = [c.strip() for c in line.strip('|').split('|')]
+ cells = (cells + [''] * len(headers))[:len(headers)]
+ if not any(cells):
+ continue
+ result.append(f"> *{headers[0]}*: {cells[0] or '--'}")
+ for i, cell in enumerate(cells[1:], 1):
+ if cell and i < len(headers):
+ result.append(f" \u2022 *{headers[i]}*: {cell}")
+ result.append("")
+ return '\n'.join(result).rstrip()
From ffbb264a5d41941f793ba4910de26df04874ec26 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 16 Feb 2026 12:11:03 +0000
Subject: [PATCH 131/506] fix: consistent sender_id for Telegram command
allowlist matching
---
nanobot/channels/telegram.py | 20 ++++++++------------
1 file changed, 8 insertions(+), 12 deletions(-)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index efd009e..d2ce74c 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -222,18 +222,18 @@ class TelegramChannel(BaseChannel):
"Type /help to see 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
-
- user = update.effective_user
- sender_id = str(user.id)
- if user.username:
- sender_id = f"{sender_id}|{user.username}"
-
await self._handle_message(
- sender_id=sender_id,
+ sender_id=self._sender_id(update.effective_user),
chat_id=str(update.message.chat_id),
content=update.message.text,
)
@@ -246,11 +246,7 @@ class TelegramChannel(BaseChannel):
message = update.message
user = update.effective_user
chat_id = message.chat_id
-
- # Use stable numeric ID, but keep username for allowlist compatibility
- sender_id = str(user.id)
- if user.username:
- sender_id = f"{sender_id}|{user.username}"
+ sender_id = self._sender_id(user)
# Store chat_id for replies
self._chat_ids[sender_id] = chat_id
From 8f49b52079137af90aa56cb164848add110f284e Mon Sep 17 00:00:00 2001
From: Kiplangatkorir
Date: Mon, 16 Feb 2026 15:22:15 +0300
Subject: [PATCH 132/506] Scope sessions to workspace with legacy fallback
---
nanobot/session/manager.py | 38 +++++++++++++++++++++++++++++++++++---
1 file changed, 35 insertions(+), 3 deletions(-)
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index bce12a1..e2d8e5c 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -42,8 +42,30 @@ class Session:
self.updated_at = datetime.now()
def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
- """Get recent messages in LLM format (role + content only)."""
- return [{"role": m["role"], "content": m["content"]} for m in self.messages[-max_messages:]]
+ """
+ Get recent messages in LLM format.
+
+ Preserves tool metadata for replay/debugging fidelity.
+ """
+ history: list[dict[str, Any]] = []
+ for msg in self.messages[-max_messages:]:
+ llm_msg: dict[str, Any] = {
+ "role": msg["role"],
+ "content": msg.get("content", ""),
+ }
+
+ if msg["role"] == "assistant" and "tool_calls" in msg:
+ llm_msg["tool_calls"] = msg["tool_calls"]
+
+ if msg["role"] == "tool":
+ if "tool_call_id" in msg:
+ llm_msg["tool_call_id"] = msg["tool_call_id"]
+ if "name" in msg:
+ llm_msg["name"] = msg["name"]
+
+ history.append(llm_msg)
+
+ return history
def clear(self) -> None:
"""Clear all messages and reset session to initial state."""
@@ -61,13 +83,19 @@ class SessionManager:
def __init__(self, workspace: Path):
self.workspace = workspace
- self.sessions_dir = ensure_dir(Path.home() / ".nanobot" / "sessions")
+ self.sessions_dir = ensure_dir(self.workspace / "sessions")
+ self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
self._cache: dict[str, Session] = {}
def _get_session_path(self, key: str) -> Path:
"""Get the file path for a session."""
safe_key = safe_filename(key.replace(":", "_"))
return self.sessions_dir / f"{safe_key}.jsonl"
+
+ def _get_legacy_session_path(self, key: str) -> Path:
+ """Get the legacy global session path for backward compatibility."""
+ safe_key = safe_filename(key.replace(":", "_"))
+ return self.legacy_sessions_dir / f"{safe_key}.jsonl"
def get_or_create(self, key: str) -> Session:
"""
@@ -92,6 +120,10 @@ class SessionManager:
def _load(self, key: str) -> Session | None:
"""Load a session from disk."""
path = self._get_session_path(key)
+ if not path.exists():
+ legacy_path = self._get_legacy_session_path(key)
+ if legacy_path.exists():
+ path = legacy_path
if not path.exists():
return None
From db0e8aa61b2a69fc81a43a04c86dfbe67d3b2631 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 16 Feb 2026 12:39:39 +0000
Subject: [PATCH 133/506] fix: handle Telegram message length limit with smart
splitting
---
.gitignore | 2 +-
README.md | 2 +-
nanobot/channels/telegram.py | 28 ++-
tests/test_telegram_channel.py | 416 ---------------------------------
4 files changed, 18 insertions(+), 430 deletions(-)
delete mode 100644 tests/test_telegram_channel.py
diff --git a/.gitignore b/.gitignore
index 742d593..d7b930d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,4 +19,4 @@ __pycache__/
poetry.lock
.pytest_cache/
botpy.log
-
+tests/
diff --git a/README.md b/README.md
index 6a3ec3e..0584dd8 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,663 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,668 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 8166a9d..c9978c2 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -82,17 +82,20 @@ 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 = []
- while len(content) > max_len:
- chunk = content[:max_len]
- break_pos = chunk.rfind('\n')
- if break_pos == -1:
- break_pos = chunk.rfind(' ')
- if break_pos == -1:
- break_pos = max_len
- chunks.append(chunk[:break_pos])
- content = content[break_pos:].lstrip()
- return chunks + [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):
@@ -211,7 +214,8 @@ class TelegramChannel(BaseChannel):
for chunk in _split_message(msg.content):
try:
- await self._app.bot.send_message(chat_id=chat_id, text=_markdown_to_telegram_html(chunk), parse_mode="HTML")
+ html = _markdown_to_telegram_html(chunk)
+ await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML")
except Exception as e:
logger.warning(f"HTML parse failed, falling back to plain text: {e}")
try:
diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py
deleted file mode 100644
index 8e9a4d4..0000000
--- a/tests/test_telegram_channel.py
+++ /dev/null
@@ -1,416 +0,0 @@
-"""Tests for Telegram channel implementation."""
-
-import pytest
-from unittest.mock import AsyncMock, MagicMock
-
-from nanobot.bus.events import OutboundMessage
-from nanobot.bus.queue import MessageBus
-from nanobot.channels.telegram import TelegramChannel, _markdown_to_telegram_html
-from nanobot.config.schema import TelegramConfig
-
-
-def _make_config() -> TelegramConfig:
- return TelegramConfig(
- enabled=True,
- token="fake-token",
- allow_from=[],
- proxy=None,
- )
-
-
-class TestMarkdownToTelegramHtml:
- """Tests for markdown to Telegram HTML conversion."""
-
- def test_empty_text(self) -> None:
- assert _markdown_to_telegram_html("") == ""
-
- def test_plain_text_passthrough(self) -> None:
- text = "Hello world"
- assert _markdown_to_telegram_html(text) == "Hello world"
-
- def test_bold_double_asterisks(self) -> None:
- text = "This is **bold** text"
- assert _markdown_to_telegram_html(text) == "This is bold text"
-
- def test_bold_double_underscore(self) -> None:
- text = "This is __bold__ text"
- assert _markdown_to_telegram_html(text) == "This is bold text"
-
- def test_italic_underscore(self) -> None:
- text = "This is _italic_ text"
- assert _markdown_to_telegram_html(text) == "This is italic text"
-
- def test_italic_not_inside_words(self) -> None:
- text = "some_var_name"
- assert _markdown_to_telegram_html(text) == "some_var_name"
-
- def test_strikethrough(self) -> None:
- text = "This is ~~deleted~~ text"
- assert _markdown_to_telegram_html(text) == "This is deleted text"
-
- def test_inline_code(self) -> None:
- text = "Use `print()` function"
- result = _markdown_to_telegram_html(text)
- assert "print()" in result
-
- def test_inline_code_escapes_html(self) -> None:
- text = "Use `` tag"
- result = _markdown_to_telegram_html(text)
- assert "
<div>" in result
-
- def test_code_block(self) -> None:
- text = """Here is code:
-```python
-def hello():
- return "world"
-```
-Done.
-"""
- result = _markdown_to_telegram_html(text)
- assert "
" in result
- assert "def hello():" in result
- assert "
" in result
-
- def test_code_block_escapes_html(self) -> None:
- text = """```
-
test
-```"""
- result = _markdown_to_telegram_html(text)
- assert "<div>test</div>" in result
-
- def test_headers_stripped(self) -> None:
- text = "# Header 1\n## Header 2\n### Header 3"
- result = _markdown_to_telegram_html(text)
- assert "# Header 1" not in result
- assert "Header 1" in result
- assert "Header 2" in result
- assert "Header 3" in result
-
- def test_blockquotes_stripped(self) -> None:
- text = "> This is a quote\nMore text"
- result = _markdown_to_telegram_html(text)
- assert "> " not in result
- assert "This is a quote" in result
-
- def test_links_converted(self) -> None:
- text = "Check [this link](https://example.com) out"
- result = _markdown_to_telegram_html(text)
- assert '
this link' in result
-
- def test_bullet_list_converted(self) -> None:
- text = "- Item 1\n* Item 2"
- result = _markdown_to_telegram_html(text)
- assert "• Item 1" in result
- assert "• Item 2" in result
-
- def test_html_special_chars_escaped(self) -> None:
- text = "5 < 10 and 10 > 5"
- result = _markdown_to_telegram_html(text)
- assert "5 < 10" in result
- assert "10 > 5" in result
-
- def test_complex_nested_formatting(self) -> None:
- text = "**Bold _and italic_** and `code`"
- result = _markdown_to_telegram_html(text)
- assert "
Bold and italic" in result
- assert "
code" in result
-
-
-class TestTelegramChannelSend:
- """Tests for TelegramChannel.send() method."""
-
- @pytest.mark.asyncio
- async def test_send_short_message_single_chunk(self, monkeypatch) -> None:
- """Short messages are sent as a single message."""
- sent_messages = []
-
- class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None):
- sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
-
- fake_app = MagicMock()
- fake_app.bot = FakeBot()
-
- channel = TelegramChannel(_make_config(), MessageBus())
- channel._app = fake_app
-
- await channel.send(OutboundMessage(
- channel="telegram",
- chat_id="123456",
- content="Hello world"
- ))
-
- assert len(sent_messages) == 1
- assert sent_messages[0]["chat_id"] == 123456
- assert "Hello world" in sent_messages[0]["text"]
- assert sent_messages[0]["parse_mode"] == "HTML"
-
- @pytest.mark.asyncio
- async def test_send_long_message_split_into_chunks(self, monkeypatch) -> None:
- """Long messages exceeding 4000 chars are split."""
- sent_messages = []
-
- class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None):
- sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
-
- fake_app = MagicMock()
- fake_app.bot = FakeBot()
-
- channel = TelegramChannel(_make_config(), MessageBus())
- channel._app = fake_app
-
- # Create a message longer than 4000 chars
- long_content = "A" * 1000 + "\n" + "B" * 1000 + "\n" + "C" * 1000 + "\n" + "D" * 1000 + "\n" + "E" * 1000
-
- await channel.send(OutboundMessage(
- channel="telegram",
- chat_id="123456",
- content=long_content
- ))
-
- assert len(sent_messages) == 2 # Should be split into 2 messages
- assert all(m["chat_id"] == 123456 for m in sent_messages)
-
- @pytest.mark.asyncio
- async def test_send_splits_at_newline_when_possible(self, monkeypatch) -> None:
- """Message splitting prefers newline boundaries."""
- sent_messages = []
-
- class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None):
- sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
-
- fake_app = MagicMock()
- fake_app.bot = FakeBot()
-
- channel = TelegramChannel(_make_config(), MessageBus())
- channel._app = fake_app
-
- # Create content with clear paragraph breaks
- paragraphs = [f"Paragraph {i}: " + "x" * 100 for i in range(50)]
- content = "\n".join(paragraphs)
-
- await channel.send(OutboundMessage(
- channel="telegram",
- chat_id="123456",
- content=content
- ))
-
- # Each chunk should end with a complete paragraph (no partial lines)
- for msg in sent_messages:
- # Message should not start with whitespace after stripping
- text = msg["text"]
- assert text == text.lstrip()
-
- @pytest.mark.asyncio
- async def test_send_falls_back_to_space_boundary(self, monkeypatch) -> None:
- """When no newline available, split at space boundary."""
- sent_messages = []
-
- class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None):
- sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
-
- fake_app = MagicMock()
- fake_app.bot = FakeBot()
-
- channel = TelegramChannel(_make_config(), MessageBus())
- channel._app = fake_app
-
- # Long content without newlines but with spaces
- content = "word " * 2000 # ~10000 chars
-
- await channel.send(OutboundMessage(
- channel="telegram",
- chat_id="123456",
- content=content
- ))
-
- assert len(sent_messages) >= 2
-
- @pytest.mark.asyncio
- async def test_send_forces_split_when_no_good_boundary(self, monkeypatch) -> None:
- """When no newline or space, force split at max length."""
- sent_messages = []
-
- class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None):
- sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
-
- fake_app = MagicMock()
- fake_app.bot = FakeBot()
-
- channel = TelegramChannel(_make_config(), MessageBus())
- channel._app = fake_app
-
- # Long content without any spaces or newlines
- content = "A" * 10000
-
- await channel.send(OutboundMessage(
- channel="telegram",
- chat_id="123456",
- content=content
- ))
-
- assert len(sent_messages) >= 2
- # Verify all chunks combined equal original
- combined = "".join(m["text"] for m in sent_messages)
- assert combined == content
-
- @pytest.mark.asyncio
- async def test_send_invalid_chat_id_logs_error(self, monkeypatch) -> None:
- """Invalid chat_id should log error and not send."""
- sent_messages = []
-
- class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None):
- sent_messages.append({"chat_id": chat_id, "text": text})
-
- fake_app = MagicMock()
- fake_app.bot = FakeBot()
-
- channel = TelegramChannel(_make_config(), MessageBus())
- channel._app = fake_app
-
- await channel.send(OutboundMessage(
- channel="telegram",
- chat_id="not-a-number",
- content="Hello"
- ))
-
- assert len(sent_messages) == 0
-
- @pytest.mark.asyncio
- async def test_send_html_parse_error_falls_back_to_plain_text(self, monkeypatch) -> None:
- """When HTML parsing fails, fall back to plain text."""
- sent_messages = []
- call_count = 0
-
- class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None):
- nonlocal call_count
- call_count += 1
- if parse_mode == "HTML" and call_count == 1:
- raise Exception("Bad markup")
- sent_messages.append({"chat_id": chat_id, "text": text, "parse_mode": parse_mode})
-
- fake_app = MagicMock()
- fake_app.bot = FakeBot()
-
- channel = TelegramChannel(_make_config(), MessageBus())
- channel._app = fake_app
-
- await channel.send(OutboundMessage(
- channel="telegram",
- chat_id="123456",
- content="Hello **world**"
- ))
-
- # Should have 2 calls: first HTML (fails), second plain text (succeeds)
- assert call_count == 2
- assert len(sent_messages) == 1
- assert sent_messages[0]["parse_mode"] is None # Plain text
- assert "Hello **world**" in sent_messages[0]["text"]
-
- @pytest.mark.asyncio
- async def test_send_not_running_warns(self, monkeypatch) -> None:
- """If bot not running, log warning."""
- warning_logged = []
-
- def mock_warning(msg, *args):
- warning_logged.append(msg)
-
- monkeypatch.setattr("nanobot.channels.telegram.logger", MagicMock(warning=mock_warning))
-
- channel = TelegramChannel(_make_config(), MessageBus())
- channel._app = None # Not running
-
- await channel.send(OutboundMessage(
- channel="telegram",
- chat_id="123456",
- content="Hello"
- ))
-
- assert any("not running" in str(m) for m in warning_logged)
-
- @pytest.mark.asyncio
- async def test_send_stops_typing_indicator(self, monkeypatch) -> None:
- """Sending message should stop typing indicator."""
- stopped_chats = []
-
- class FakeBot:
- async def send_message(self, chat_id, text, parse_mode=None):
- pass
-
- fake_app = MagicMock()
- fake_app.bot = FakeBot()
-
- channel = TelegramChannel(_make_config(), MessageBus())
- channel._app = fake_app
- channel._stop_typing = lambda chat_id: stopped_chats.append(chat_id)
-
- await channel.send(OutboundMessage(
- channel="telegram",
- chat_id="123456",
- content="Hello"
- ))
-
- assert "123456" in stopped_chats
-
-
-class TestTelegramChannelTyping:
- """Tests for typing indicator functionality."""
-
- @pytest.mark.asyncio
- async def test_start_typing_creates_task(self) -> None:
- channel = TelegramChannel(_make_config(), MessageBus())
-
- # Mock _typing_loop to avoid actual async execution
- channel._typing_loop = AsyncMock()
-
- channel._start_typing("123456")
-
- assert "123456" in channel._typing_tasks
- assert not channel._typing_tasks["123456"].done()
-
- # Clean up
- channel._stop_typing("123456")
-
- def test_stop_typing_cancels_task(self) -> None:
- channel = TelegramChannel(_make_config(), MessageBus())
-
- # Create a mock task
- mock_task = MagicMock()
- mock_task.done.return_value = False
- channel._typing_tasks["123456"] = mock_task
-
- channel._stop_typing("123456")
-
- mock_task.cancel.assert_called_once()
- assert "123456" not in channel._typing_tasks
-
-
-class TestTelegramChannelMediaExtensions:
- """Tests for media file extension detection."""
-
- def test_get_extension_from_mime_type(self) -> None:
- channel = TelegramChannel(_make_config(), MessageBus())
-
- assert channel._get_extension("image", "image/jpeg") == ".jpg"
- assert channel._get_extension("image", "image/png") == ".png"
- assert channel._get_extension("image", "image/gif") == ".gif"
- assert channel._get_extension("audio", "audio/ogg") == ".ogg"
- assert channel._get_extension("audio", "audio/mpeg") == ".mp3"
-
- def test_get_extension_fallback_to_type(self) -> None:
- channel = TelegramChannel(_make_config(), MessageBus())
-
- assert channel._get_extension("image", None) == ".jpg"
- assert channel._get_extension("voice", None) == ".ogg"
- assert channel._get_extension("audio", None) == ".mp3"
-
- def test_get_extension_unknown_type(self) -> None:
- channel = TelegramChannel(_make_config(), MessageBus())
-
- assert channel._get_extension("unknown", None) == ""
From ed5593bbe094683135078407acd2d263d056f6c3 Mon Sep 17 00:00:00 2001
From: Grzegorz Grasza
Date: Mon, 16 Feb 2026 13:53:24 +0100
Subject: [PATCH 134/506] slack: use slackify-markdown for proper mrkdwn
formatting
Replace the regex-based Markdown-to-Slack converter with the
slackify-markdown library, which uses a proper Markdown parser
(markdown-it-py, already a dependency) to correctly handle headings,
bold/italic, code blocks, links, bullet lists, and strikethrough.
The regex approach didn't handle headings (###), bullet lists (* ),
or code block protection, causing raw Markdown to leak into Slack
messages.
Net -40 lines.
Assisted-by: Claude 4.6 Opus (Anthropic)
---
nanobot/channels/slack.py | 47 +++------------------------------------
pyproject.toml | 1 +
2 files changed, 4 insertions(+), 44 deletions(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index dd18e79..a56ee1a 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -10,6 +10,8 @@ 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
@@ -84,7 +86,7 @@ class SlackChannel(BaseChannel):
use_thread = thread_ts and channel_type != "im"
await self._web_client.chat_postMessage(
channel=msg.chat_id,
- text=self._convert_markdown(msg.content) or "",
+ text=slackify_markdown(msg.content) if msg.content else "",
thread_ts=thread_ts if use_thread else None,
)
except Exception as e:
@@ -204,46 +206,3 @@ class SlackChannel(BaseChannel):
return text
return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
- # Markdown → Slack mrkdwn formatting rules (order matters: longest markers first)
- _MD_TO_SLACK = (
- (r'(?m)(^|[^\*])\*\*\*(.+?)\*\*\*([^\*]|$)', r'\1*_\2_*\3'), # ***bold italic***
- (r'(?m)(^|[^_])___(.+?)___([^_]|$)', r'\1*_\2_*\3'), # ___bold italic___
- (r'(?m)(^|[^\*])\*\*(.+?)\*\*([^\*]|$)', r'\1*\2*\3'), # **bold**
- (r'(?m)(^|[^_])__(.+?)__([^_]|$)', r'\1*\2*\3'), # __bold__
- (r'(?m)(^|[^\*])\*(.+?)\*([^\*]|$)', r'\1_\2_\3'), # *italic*
- (r'(?m)(^|[^~])~~(.+?)~~([^~]|$)', r'\1~\2~\3'), # ~~strike~~
- (r'(?m)(^|[^!])\[(.+?)\]\((http.+?)\)', r'\1<\3|\2>'), # [text](url)
- (r'!\[.+?\]\((http.+?)(?:\s".*?")?\)', r'<\1>'), # 
- )
- _TABLE_RE = re.compile(r'(?m)^\|.*?\|$(?:\n(?:\|\:?-{3,}\:?)*?\|$)(?:\n\|.*?\|$)*')
-
- def _convert_markdown(self, text: str) -> str:
- """Convert standard Markdown to Slack mrkdwn format."""
- if not text:
- return text
- for pattern, repl in self._MD_TO_SLACK:
- text = re.sub(pattern, repl, text)
- return self._TABLE_RE.sub(self._convert_table, text)
-
- @staticmethod
- def _convert_table(match: re.Match) -> str:
- """Convert Markdown table to Slack quote + bullet format."""
- lines = [l.strip() for l in match.group(0).strip().split('\n') if l.strip()]
- if len(lines) < 2:
- return match.group(0)
-
- headers = [h.strip() for h in lines[0].strip('|').split('|')]
- start = 2 if not re.search(r'[^|\-\s:]', lines[1]) else 1
-
- result: list[str] = []
- for line in lines[start:]:
- cells = [c.strip() for c in line.strip('|').split('|')]
- cells = (cells + [''] * len(headers))[:len(headers)]
- if not any(cells):
- continue
- result.append(f"> *{headers[0]}*: {cells[0] or '--'}")
- for i, cell in enumerate(cells[1:], 1):
- if cell and i < len(headers):
- result.append(f" \u2022 *{headers[i]}*: {cell}")
- result.append("")
- return '\n'.join(result).rstrip()
diff --git a/pyproject.toml b/pyproject.toml
index f5fd60c..6261653 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,7 @@ dependencies = [
"python-socketio>=5.11.0",
"msgpack>=1.0.8",
"slack-sdk>=3.26.0",
+ "slackify-markdown>=0.2.0",
"qq-botpy>=1.0.0",
"python-socks[asyncio]>=2.4.0",
"prompt-toolkit>=3.0.0",
From c9926153b263e0a451c37de89a75024ab5a2d4bf Mon Sep 17 00:00:00 2001
From: Grzegorz Grasza
Date: Mon, 16 Feb 2026 14:03:33 +0100
Subject: [PATCH 135/506] Add table-to-text conversion for Slack messages
Slack has no native table support, so Markdown tables are passed
through verbatim by slackify-markdown. Pre-process tables into
readable key-value rows before converting to mrkdwn.
Assisted-by: Claude 4.6 Opus (Anthropic)
---
nanobot/channels/slack.py | 29 ++++++++++++++++++++++++++++-
1 file changed, 28 insertions(+), 1 deletion(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index a56ee1a..e5fff63 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -86,7 +86,7 @@ class SlackChannel(BaseChannel):
use_thread = thread_ts and channel_type != "im"
await self._web_client.chat_postMessage(
channel=msg.chat_id,
- text=slackify_markdown(msg.content) if msg.content else "",
+ text=self._to_mrkdwn(msg.content),
thread_ts=thread_ts if use_thread else None,
)
except Exception as e:
@@ -206,3 +206,30 @@ class SlackChannel(BaseChannel):
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)
+
From a219a91bc5e41a311d7e48b752aa489039ccd281 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 16 Feb 2026 13:42:33 +0000
Subject: [PATCH 136/506] feat: support openclaw/clawhub skill metadata format
---
nanobot/agent/skills.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py
index ead9f5b..5b841f3 100644
--- a/nanobot/agent/skills.py
+++ b/nanobot/agent/skills.py
@@ -167,10 +167,10 @@ class SkillsLoader:
return content
def _parse_nanobot_metadata(self, raw: str) -> dict:
- """Parse nanobot metadata JSON from frontmatter."""
+ """Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
try:
data = json.loads(raw)
- return data.get("nanobot", {}) if isinstance(data, dict) else {}
+ return data.get("nanobot", data.get("openclaw", {})) if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
From 5033ac175987d08f8e28f39c1fe346a593d72d73 Mon Sep 17 00:00:00 2001
From: Darye <54469750+DaryeDev@users.noreply.github.com>
Date: Mon, 16 Feb 2026 15:02:12 +0100
Subject: [PATCH 137/506] Added Github Copilot Provider
---
nanobot/cli/commands.py | 2 +-
nanobot/config/schema.py | 1 +
nanobot/providers/registry.py | 19 +++++++++++++++++++
3 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 235bfdc..affd421 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -292,7 +292,7 @@ def _make_provider(config: Config):
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model)
- if not model.startswith("bedrock/") and not (p and p.api_key):
+ if not model.startswith("bedrock/") and not (p and p.api_key) and provider_name != "github_copilot":
console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 15b6bb2..64609ec 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -193,6 +193,7 @@ class ProvidersConfig(BaseModel):
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
+ github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
class GatewayConfig(BaseModel):
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 59af5e1..1e760d6 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -177,6 +177,25 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
is_oauth=True, # OAuth-based authentication
),
+ # Github Copilot: uses OAuth, not API key.
+ ProviderSpec(
+ name="github_copilot",
+ keywords=("github_copilot", "copilot"),
+ env_key="", # OAuth-based, no API key
+ display_name="Github Copilot",
+ litellm_prefix="github_copilot", # github_copilot/model → github_copilot/model
+ skip_prefixes=("github_copilot/",),
+ env_extras=(),
+ is_gateway=False,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="",
+ default_api_base="",
+ strip_model_prefix=False,
+ model_overrides=(),
+ is_oauth=True, # OAuth-based authentication
+ ),
+
# DeepSeek: needs "deepseek/" prefix for LiteLLM routing.
ProviderSpec(
name="deepseek",
From 23b7e1ef5e944a05653342a70efa2e1fbba9109f Mon Sep 17 00:00:00 2001
From: Darye <54469750+DaryeDev@users.noreply.github.com>
Date: Mon, 16 Feb 2026 16:29:03 +0100
Subject: [PATCH 138/506] Handle media files (voice messages, audio, images,
documents) on Telegram Channel
---
nanobot/agent/tools/message.py | 12 +++++--
nanobot/channels/telegram.py | 64 +++++++++++++++++++++++++++++-----
2 files changed, 65 insertions(+), 11 deletions(-)
diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py
index 347830f..3853725 100644
--- a/nanobot/agent/tools/message.py
+++ b/nanobot/agent/tools/message.py
@@ -52,6 +52,11 @@ class MessageTool(Tool):
"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"]
@@ -62,6 +67,7 @@ class MessageTool(Tool):
content: str,
channel: str | None = None,
chat_id: str | None = None,
+ media: list[str] | None = None,
**kwargs: Any
) -> str:
channel = channel or self._default_channel
@@ -76,11 +82,13 @@ class MessageTool(Tool):
msg = OutboundMessage(
channel=channel,
chat_id=chat_id,
- content=content
+ content=content,
+ media=media or []
)
try:
await self._send_callback(msg)
- return f"Message sent to {channel}:{chat_id}"
+ 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/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index c9978c2..8f135e4 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -198,6 +198,18 @@ class TelegramChannel(BaseChannel):
await self._app.shutdown()
self._app = None
+ def _get_media_type(self, path: str) -> str:
+ """Guess media type from file extension."""
+ path = path.lower()
+ if path.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
+ return "photo"
+ elif path.endswith('.ogg'):
+ return "voice"
+ elif path.endswith(('.mp3', '.m4a', '.wav', '.aac')):
+ return "audio"
+ else:
+ return "document"
+
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Telegram."""
if not self._app:
@@ -212,16 +224,50 @@ class TelegramChannel(BaseChannel):
logger.error(f"Invalid chat_id: {msg.chat_id}")
return
- 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")
- except Exception as e:
- logger.warning(f"HTML parse failed, falling back to plain text: {e}")
+ # Handle media files
+ if msg.media:
+ for media_path in msg.media:
try:
- await self._app.bot.send_message(chat_id=chat_id, text=chunk)
- except Exception as e2:
- logger.error(f"Error sending Telegram message: {e2}")
+ media_type = self._get_media_type(media_path)
+
+ # Determine caption (only for first media or if explicitly set,
+ # but here we keep it simple: content is sent separately if media is present
+ # to avoid length issues, unless we want to attach it to the first media)
+ # For simplicity: send media first, then text if present.
+ # Or: if single media, attach text as caption.
+
+ # Let's attach content as caption to the last media if single,
+ # otherwise send text separately.
+
+ with open(media_path, 'rb') as f:
+ if media_type == "photo":
+ await self._app.bot.send_photo(chat_id=chat_id, photo=f)
+ elif media_type == "voice":
+ await self._app.bot.send_voice(chat_id=chat_id, voice=f)
+ elif media_type == "audio":
+ await self._app.bot.send_audio(chat_id=chat_id, audio=f)
+ else:
+ await self._app.bot.send_document(chat_id=chat_id, document=f)
+
+ except Exception as e:
+ logger.error(f"Failed to send media {media_path}: {e}")
+ await self._app.bot.send_message(
+ chat_id=chat_id,
+ text=f"[Failed to send file: {media_path}]"
+ )
+
+ # Send text content if present
+ 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")
+ except Exception as e:
+ logger.warning(f"HTML parse failed, falling back to plain text: {e}")
+ try:
+ await self._app.bot.send_message(chat_id=chat_id, text=chunk)
+ except Exception as e2:
+ logger.error(f"Error sending Telegram message: {e2}")
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command."""
From ae903e983c2c0a7aac3f57fc9a66f7e13dbf4456 Mon Sep 17 00:00:00 2001
From: jopo
Date: Mon, 16 Feb 2026 17:49:19 -0800
Subject: [PATCH 139/506] fix(cron): improve timezone scheduling and tz
propagation
---
nanobot/agent/tools/cron.py | 20 +++++++++++++++++---
nanobot/cli/commands.py | 22 +++++++++++++++++++---
nanobot/cron/service.py | 3 ++-
3 files changed, 38 insertions(+), 7 deletions(-)
diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py
index 9f1ecdb..9dc31c2 100644
--- a/nanobot/agent/tools/cron.py
+++ b/nanobot/agent/tools/cron.py
@@ -50,6 +50,10 @@ class CronTool(Tool):
"type": "string",
"description": "Cron expression like '0 9 * * *' (for scheduled tasks)"
},
+ "tz": {
+ "type": "string",
+ "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')"
+ },
"at": {
"type": "string",
"description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')"
@@ -68,30 +72,40 @@ class CronTool(Tool):
message: str = "",
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:
if action == "add":
- return self._add_job(message, every_seconds, cron_expr, at)
+ return self._add_job(message, every_seconds, cron_expr, tz, at)
elif action == "list":
return self._list_jobs()
elif action == "remove":
return self._remove_job(job_id)
return f"Unknown action: {action}"
- def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str:
+ def _add_job(
+ self,
+ message: str,
+ every_seconds: int | None,
+ cron_expr: str | None,
+ tz: str | None,
+ at: str | None,
+ ) -> str:
if not message:
return "Error: message is required for add"
if not self._channel or not self._chat_id:
return "Error: no session context (channel/chat_id)"
+ if tz and not cron_expr:
+ return "Error: tz can only be used with cron_expr"
# Build schedule
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)
+ schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at:
from datetime import datetime
dt = datetime.fromisoformat(at)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 235bfdc..3b58db5 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -719,21 +719,32 @@ def cron_list(
table.add_column("Status")
table.add_column("Next Run")
+ import datetime
import time
+ 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 = job.schedule.expr or ""
+ if job.schedule.tz:
+ sched = f"{sched} ({job.schedule.tz})"
else:
sched = "one-time"
# Format next run
next_run = ""
if job.state.next_run_at_ms:
- next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000))
- next_run = next_time
+ ts = job.state.next_run_at_ms / 1000
+ if job.schedule.kind == "cron" and job.schedule.tz:
+ try:
+ dt = datetime.fromtimestamp(ts, ZoneInfo(job.schedule.tz))
+ next_run = dt.strftime("%Y-%m-%d %H:%M")
+ except Exception:
+ next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
+ else:
+ next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
@@ -748,6 +759,7 @@ def cron_add(
message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
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"),
@@ -758,11 +770,15 @@ def cron_add(
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
+ if tz and not cron_expr:
+ console.print("[red]Error: --tz can only be used with --cron[/red]")
+ raise typer.Exit(1)
+
# Determine schedule type
if every:
schedule = CronSchedule(kind="every", every_ms=every * 1000)
elif cron_expr:
- schedule = CronSchedule(kind="cron", expr=cron_expr)
+ schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
elif at:
import datetime
dt = datetime.datetime.fromisoformat(at)
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index 4da845a..9fda214 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -32,7 +32,8 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
try:
from croniter import croniter
from zoneinfo import ZoneInfo
- base_time = time.time()
+ # Use the caller-provided reference time for deterministic scheduling.
+ base_time = now_ms / 1000
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)
From 778a93370a7cfaa17c5efdec09487d1ff67f8389 Mon Sep 17 00:00:00 2001
From: Darye <54469750+DaryeDev@users.noreply.github.com>
Date: Tue, 17 Feb 2026 03:52:54 +0100
Subject: [PATCH 140/506] Enable Cron management on CLI Agent.
---
nanobot/cli/commands.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 235bfdc..1f6b2f5 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -439,9 +439,10 @@ def agent(
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
"""Interact with the agent directly."""
- from nanobot.config.loader import load_config
+ from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
+ from nanobot.cron.service import CronService
from loguru import logger
config = load_config()
@@ -449,6 +450,10 @@ def agent(
bus = MessageBus()
provider = _make_provider(config)
+ # Create cron service for tool usage (no callback needed for CLI unless running)
+ cron_store_path = get_data_dir() / "cron" / "jobs.json"
+ cron = CronService(cron_store_path)
+
if logs:
logger.enable("nanobot")
else:
@@ -465,6 +470,7 @@ def agent(
memory_window=config.agents.defaults.memory_window,
brave_api_key=config.tools.web.search.api_key or None,
exec_config=config.tools.exec,
+ cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
)
From 56bc8b567733480091d0f4eb4636d8bd31ded1ac Mon Sep 17 00:00:00 2001
From: nano bot
Date: Tue, 17 Feb 2026 03:52:08 +0000
Subject: [PATCH 141/506] fix: avoid sending empty content entries in assistant
messages
---
nanobot/agent/context.py | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index f460f2b..bed9e36 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -225,14 +225,20 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
Returns:
Updated message list.
"""
- msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
-
+ msg: dict[str, Any] = {"role": "assistant"}
+
+ # Only include the content key when there is non-empty text.
+ # Some LLM backends reject empty text blocks, so omit the key
+ # to avoid sending empty content entries.
+ if content is not None and content != "":
+ msg["content"] = content
+
if tool_calls:
msg["tool_calls"] = tool_calls
-
- # Thinking models reject history without this
+
+ # Include reasoning content when provided (required by some thinking models)
if reasoning_content:
msg["reasoning_content"] = reasoning_content
-
+
messages.append(msg)
return messages
From 5735f9bdcee8f7a415cad6b206dd315c1f94f5f1 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 08:14:16 +0000
Subject: [PATCH 142/506] feat: add ClawHub skill for searching and installing
agent skills from the public registry
---
nanobot/skills/README.md | 1 +
nanobot/skills/clawhub/SKILL.md | 53 +++++++++++++++++++++++++++++++++
2 files changed, 54 insertions(+)
create mode 100644 nanobot/skills/clawhub/SKILL.md
diff --git a/nanobot/skills/README.md b/nanobot/skills/README.md
index f0dcea7..5192796 100644
--- a/nanobot/skills/README.md
+++ b/nanobot/skills/README.md
@@ -21,4 +21,5 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai
| `weather` | Get weather info using wttr.in and Open-Meteo |
| `summarize` | Summarize URLs, files, and YouTube videos |
| `tmux` | Remote-control tmux sessions |
+| `clawhub` | Search and install skills from ClawHub registry |
| `skill-creator` | Create new skills |
\ No newline at end of file
diff --git a/nanobot/skills/clawhub/SKILL.md b/nanobot/skills/clawhub/SKILL.md
new file mode 100644
index 0000000..7409bf4
--- /dev/null
+++ b/nanobot/skills/clawhub/SKILL.md
@@ -0,0 +1,53 @@
+---
+name: clawhub
+description: Search and install agent skills from ClawHub, the public skill registry.
+homepage: https://clawhub.ai
+metadata: {"nanobot":{"emoji":"🦞"}}
+---
+
+# ClawHub
+
+Public skill registry for AI agents. Search by natural language (vector search).
+
+## When to use
+
+Use this skill when the user asks any of:
+- "find a skill for …"
+- "search for skills"
+- "install a skill"
+- "what skills are available?"
+- "update my skills"
+
+## Search
+
+```bash
+npx --yes clawhub@latest search "web scraping" --limit 5
+```
+
+## Install
+
+```bash
+npx --yes clawhub@latest install --workdir ~/.nanobot/workspace
+```
+
+Replace `` with the skill name from search results. This places the skill into `~/.nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`.
+
+## Update
+
+```bash
+npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace
+```
+
+## List installed
+
+```bash
+npx --yes clawhub@latest list --workdir ~/.nanobot/workspace
+```
+
+## Notes
+
+- Requires Node.js (`npx` comes with it).
+- No API key needed for search and install.
+- Login (`npx --yes clawhub@latest login`) is only required for publishing.
+- `--workdir ~/.nanobot/workspace` is critical — without it, skills install to the current directory instead of the nanobot workspace.
+- After install, remind the user to start a new session to load the skill.
From 8509a81120201dd4783277f202698984fe9ee151 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 08:19:23 +0000
Subject: [PATCH 143/506] docs: update 15/16 Feb news
---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index 0584dd8..a27bbc8 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,8 @@
## 📢 News
+- **2026-02-16** 🦞 nanobot now integrates with [ClawHub](https://clawhub.ai) — search and install agent skills from the public registry.
+- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details.
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
From cf4dce5df05008b2da0b88445a1b4bd76e1aaf5a Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 08:20:50 +0000
Subject: [PATCH 144/506] docs: update clawhub news
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index a27bbc8..38afa82 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## 📢 News
-- **2026-02-16** 🦞 nanobot now integrates with [ClawHub](https://clawhub.ai) — search and install agent skills from the public registry.
+- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details.
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
From 6bae6a617f7130e0a1021811b8cd8b379c2c0820 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 08:30:52 +0000
Subject: [PATCH 145/506] fix(cron): fix timezone display bug, add tz
validation and skill docs
---
README.md | 2 +-
nanobot/agent/tools/cron.py | 6 ++++++
nanobot/cli/commands.py | 17 ++++++-----------
nanobot/cron/service.py | 2 +-
nanobot/skills/cron/SKILL.md | 10 ++++++++++
5 files changed, 24 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 38afa82..de517d7 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,668 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,689 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py
index 9dc31c2..b10e34b 100644
--- a/nanobot/agent/tools/cron.py
+++ b/nanobot/agent/tools/cron.py
@@ -99,6 +99,12 @@ class CronTool(Tool):
return "Error: no session context (channel/chat_id)"
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}'"
# Build schedule
delete_after = False
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 3b58db5..3798813 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -719,17 +719,15 @@ def cron_list(
table.add_column("Status")
table.add_column("Next Run")
- import datetime
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 = job.schedule.expr or ""
- if job.schedule.tz:
- sched = f"{sched} ({job.schedule.tz})"
+ sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
else:
sched = "one-time"
@@ -737,13 +735,10 @@ def cron_list(
next_run = ""
if job.state.next_run_at_ms:
ts = job.state.next_run_at_ms / 1000
- if job.schedule.kind == "cron" and job.schedule.tz:
- try:
- dt = datetime.fromtimestamp(ts, ZoneInfo(job.schedule.tz))
- next_run = dt.strftime("%Y-%m-%d %H:%M")
- except Exception:
- next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
- else:
+ 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))
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index 9fda214..14666e8 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -32,7 +32,7 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
try:
from croniter import croniter
from zoneinfo import ZoneInfo
- # Use the caller-provided reference time for deterministic scheduling.
+ # Use caller-provided reference time for deterministic scheduling
base_time = now_ms / 1000
tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo
base_dt = datetime.fromtimestamp(base_time, tz=tz)
diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md
index 7db25d8..cc3516e 100644
--- a/nanobot/skills/cron/SKILL.md
+++ b/nanobot/skills/cron/SKILL.md
@@ -30,6 +30,11 @@ One-time scheduled task (compute ISO datetime from current time):
cron(action="add", message="Remind me about the meeting", at="")
```
+Timezone-aware cron:
+```
+cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver")
+```
+
List/remove:
```
cron(action="list")
@@ -44,4 +49,9 @@ cron(action="remove", job_id="abc123")
| every hour | every_seconds: 3600 |
| every day at 8am | cron_expr: "0 8 * * *" |
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
+| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
| at a specific time | at: ISO datetime string (compute from current time) |
+
+## Timezone
+
+Use `tz` with `cron_expr` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used.
From f5c5b13ff03316b925bd37b58adce144d4153c92 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 08:41:09 +0000
Subject: [PATCH 146/506] refactor: use is_oauth flag instead of hardcoded
provider name check
---
README.md | 25 +++++++++++++------------
nanobot/cli/commands.py | 4 +++-
2 files changed, 16 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index de517d7..0e20449 100644
--- a/README.md
+++ b/README.md
@@ -145,19 +145,19 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ — anytime, anywhere.
+Connect nanobot to your favorite chat platform.
-| Channel | Setup |
-|---------|-------|
-| **Telegram** | Easy (just a token) |
-| **Discord** | Easy (bot token + intents) |
-| **WhatsApp** | Medium (scan QR) |
-| **Feishu** | Medium (app credentials) |
-| **Mochat** | Medium (claw token + websocket) |
-| **DingTalk** | Medium (app credentials) |
-| **Slack** | Medium (bot + app tokens) |
-| **Email** | Medium (IMAP/SMTP credentials) |
-| **QQ** | Easy (app credentials) |
+| Channel | What you need |
+|---------|---------------|
+| **Telegram** | Bot token from @BotFather |
+| **Discord** | Bot token + Message Content intent |
+| **WhatsApp** | QR code scan |
+| **Feishu** | App ID + App Secret |
+| **Mochat** | Claw token (auto-setup available) |
+| **DingTalk** | App Key + App Secret |
+| **Slack** | Bot token + App-Level token |
+| **Email** | IMAP/SMTP credentials |
+| **QQ** | App ID + App Secret |
Telegram (Recommended)
@@ -588,6 +588,7 @@ Config file: `~/.nanobot/config.json`
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
+| `github_copilot` | LLM (GitHub Copilot, OAuth) | Requires [GitHub Copilot](https://github.com/features/copilot) subscription |
OpenAI Codex (OAuth)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index a24929f..b61d9aa 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -292,7 +292,9 @@ def _make_provider(config: Config):
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model)
- if not model.startswith("bedrock/") and not (p and p.api_key) and provider_name != "github_copilot":
+ 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)
From 1db05c881ddf7b3958edda7f99ff02cd49581f5f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 08:59:05 +0000
Subject: [PATCH 147/506] fix: omit empty content in assistant messages
---
nanobot/agent/context.py | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index bed9e36..cfd6318 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -227,10 +227,8 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
"""
msg: dict[str, Any] = {"role": "assistant"}
- # Only include the content key when there is non-empty text.
- # Some LLM backends reject empty text blocks, so omit the key
- # to avoid sending empty content entries.
- if content is not None and content != "":
+ # Omit empty content — some backends reject empty text blocks
+ if content:
msg["content"] = content
if tool_calls:
From 5ad9c837df8879717a01760530b830dd3c67cd7b Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 10:37:55 +0000
Subject: [PATCH 148/506] refactor: clean up telegram media sending logic
---
nanobot/channels/telegram.py | 63 ++++++++++++++----------------------
1 file changed, 24 insertions(+), 39 deletions(-)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 8f135e4..39924b3 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -198,17 +198,17 @@ class TelegramChannel(BaseChannel):
await self._app.shutdown()
self._app = None
- def _get_media_type(self, path: str) -> str:
+ @staticmethod
+ def _get_media_type(path: str) -> str:
"""Guess media type from file extension."""
- path = path.lower()
- if path.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')):
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
+ if ext in ("jpg", "jpeg", "png", "gif", "webp"):
return "photo"
- elif path.endswith('.ogg'):
+ if ext == "ogg":
return "voice"
- elif path.endswith(('.mp3', '.m4a', '.wav', '.aac')):
+ if ext in ("mp3", "m4a", "wav", "aac"):
return "audio"
- else:
- return "document"
+ return "document"
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Telegram."""
@@ -224,39 +224,24 @@ class TelegramChannel(BaseChannel):
logger.error(f"Invalid chat_id: {msg.chat_id}")
return
- # Handle media files
- if msg.media:
- for media_path in msg.media:
- try:
- media_type = self._get_media_type(media_path)
-
- # Determine caption (only for first media or if explicitly set,
- # but here we keep it simple: content is sent separately if media is present
- # to avoid length issues, unless we want to attach it to the first media)
- # For simplicity: send media first, then text if present.
- # Or: if single media, attach text as caption.
-
- # Let's attach content as caption to the last media if single,
- # otherwise send text separately.
-
- with open(media_path, 'rb') as f:
- if media_type == "photo":
- await self._app.bot.send_photo(chat_id=chat_id, photo=f)
- elif media_type == "voice":
- await self._app.bot.send_voice(chat_id=chat_id, voice=f)
- elif media_type == "audio":
- await self._app.bot.send_audio(chat_id=chat_id, audio=f)
- else:
- await self._app.bot.send_document(chat_id=chat_id, document=f)
-
- except Exception as e:
- logger.error(f"Failed to send media {media_path}: {e}")
- await self._app.bot.send_message(
- chat_id=chat_id,
- text=f"[Failed to send file: {media_path}]"
- )
+ # 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})
+ except Exception as e:
+ filename = media_path.rsplit("/", 1)[-1]
+ logger.error(f"Failed to send media {media_path}: {e}")
+ await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]")
- # Send text content if present
+ # Send text content
if msg.content and msg.content != "[empty message]":
for chunk in _split_message(msg.content):
try:
From c03f2b670bda14557a75e77865b8a89a6670c409 Mon Sep 17 00:00:00 2001
From: Rajasimman S
Date: Tue, 17 Feb 2026 18:50:03 +0530
Subject: [PATCH 149/506] =?UTF-8?q?=F0=9F=90=B3=20feat:=20add=20Docker=20C?=
=?UTF-8?q?ompose=20support=20for=20easy=20deployment?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add docker-compose.yml with gateway and CLI services, resource limits,
and comprehensive documentation for Docker Compose usage.
---
README.md | 33 +++++++++++++++++++++++++++++++++
docker-compose.yml | 32 ++++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+)
create mode 100644 docker-compose.yml
diff --git a/README.md b/README.md
index 0e20449..f5a92fd 100644
--- a/README.md
+++ b/README.md
@@ -811,6 +811,39 @@ nanobot cron remove
> [!TIP]
> The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
+### Using Docker Compose (Recommended)
+
+The easiest way to run nanobot with Docker:
+
+```bash
+# 1. Initialize config (first time only)
+docker compose run --rm nanobot-cli onboard
+
+# 2. Edit config to add API keys
+vim ~/.nanobot/config.json
+
+# 3. Start gateway service
+docker compose up -d nanobot-gateway
+
+# 4. Check logs
+docker compose logs -f nanobot-gateway
+
+# 5. Run CLI commands
+docker compose run --rm nanobot-cli status
+docker compose run --rm nanobot-cli agent -m "Hello!"
+
+# 6. Stop services
+docker compose down
+```
+
+**Features:**
+- ✅ Resource limits (1 CPU, 1GB memory)
+- ✅ Auto-restart on failure
+- ✅ Shared configuration using YAML anchors
+- ✅ Separate CLI profile for on-demand commands
+
+### Using Docker directly
+
Build and run nanobot in a container:
```bash
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..446f5e3
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,32 @@
+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:
+ container_name: nanobot-cli
+ <<: *common-config
+ profiles:
+ - cli
+ command: ["status"]
+ stdin_open: true
+ tty: true
From 4d4d6299283f51c31357984e3a34d75518fd0501 Mon Sep 17 00:00:00 2001
From: Simon Guigui
Date: Tue, 17 Feb 2026 15:19:21 +0100
Subject: [PATCH 150/506] fix(config): mcpServers env variables should not be
converted to snake case
---
nanobot/config/loader.py | 55 ++++----------------
nanobot/config/schema.py | 109 ++++++++++++++++++++++++++-------------
2 files changed, 83 insertions(+), 81 deletions(-)
diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py
index fd7d1e8..560c1f5 100644
--- a/nanobot/config/loader.py
+++ b/nanobot/config/loader.py
@@ -2,7 +2,6 @@
import json
from pathlib import Path
-from typing import Any
from nanobot.config.schema import Config
@@ -21,43 +20,41 @@ def get_data_dir() -> 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.
"""
path = config_path or get_config_path()
-
+
if path.exists():
try:
with open(path) as f:
data = json.load(f)
data = _migrate_config(data)
- return Config.model_validate(convert_keys(data))
+ 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.")
-
+
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()
path.parent.mkdir(parents=True, exist_ok=True)
-
- # Convert to camelCase format
- data = config.model_dump()
- data = convert_to_camel(data)
-
+
+ data = config.model_dump(by_alias=True)
+
with open(path, "w") as f:
json.dump(data, f, indent=2)
@@ -70,37 +67,3 @@ def _migrate_config(data: dict) -> dict:
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
return data
-
-
-def convert_keys(data: Any) -> Any:
- """Convert camelCase keys to snake_case for Pydantic."""
- if isinstance(data, dict):
- return {camel_to_snake(k): convert_keys(v) for k, v in data.items()}
- if isinstance(data, list):
- return [convert_keys(item) for item in data]
- return data
-
-
-def convert_to_camel(data: Any) -> Any:
- """Convert snake_case keys to camelCase."""
- if isinstance(data, dict):
- return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()}
- if isinstance(data, list):
- return [convert_to_camel(item) for item in data]
- return data
-
-
-def camel_to_snake(name: str) -> str:
- """Convert camelCase to snake_case."""
- result = []
- for i, char in enumerate(name):
- if char.isupper() and i > 0:
- result.append("_")
- result.append(char.lower())
- return "".join(result)
-
-
-def snake_to_camel(name: str) -> str:
- """Convert snake_case to camelCase."""
- components = name.split("_")
- return components[0] + "".join(x.title() for x in components[1:])
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 64609ec..0786080 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -2,27 +2,39 @@
from pathlib import Path
from pydantic import BaseModel, Field, ConfigDict
+from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings
-class WhatsAppConfig(BaseModel):
+class Base(BaseModel):
+ """Base model that accepts both camelCase and snake_case keys."""
+
+ model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
+
+
+class WhatsAppConfig(Base):
"""WhatsApp channel configuration."""
+
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(BaseModel):
+class TelegramConfig(Base):
"""Telegram channel configuration."""
+
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"
+ proxy: str | None = (
+ None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
+ )
-class FeishuConfig(BaseModel):
+class FeishuConfig(Base):
"""Feishu/Lark channel configuration using WebSocket long connection."""
+
enabled: bool = False
app_id: str = "" # App ID from Feishu Open Platform
app_secret: str = "" # App Secret from Feishu Open Platform
@@ -31,24 +43,28 @@ class FeishuConfig(BaseModel):
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
-class DingTalkConfig(BaseModel):
+class DingTalkConfig(Base):
"""DingTalk channel configuration using Stream mode."""
+
enabled: bool = False
client_id: str = "" # AppKey
client_secret: str = "" # AppSecret
allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids
-class DiscordConfig(BaseModel):
+class DiscordConfig(Base):
"""Discord channel configuration."""
+
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 EmailConfig(BaseModel):
+
+class EmailConfig(Base):
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
+
enabled: bool = False
consent_granted: bool = False # Explicit owner permission to access mailbox data
@@ -70,7 +86,9 @@ class EmailConfig(BaseModel):
from_address: str = ""
# Behavior
- auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent
+ 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
@@ -78,18 +96,21 @@ class EmailConfig(BaseModel):
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
-class MochatMentionConfig(BaseModel):
+class MochatMentionConfig(Base):
"""Mochat mention behavior configuration."""
+
require_in_groups: bool = False
-class MochatGroupRule(BaseModel):
+class MochatGroupRule(Base):
"""Mochat per-group mention requirement."""
+
require_mention: bool = False
-class MochatConfig(BaseModel):
+class MochatConfig(Base):
"""Mochat channel configuration."""
+
enabled: bool = False
base_url: str = "https://mochat.io"
socket_url: str = ""
@@ -114,15 +135,17 @@ class MochatConfig(BaseModel):
reply_delay_ms: int = 120000
-class SlackDMConfig(BaseModel):
+class SlackDMConfig(Base):
"""Slack DM policy configuration."""
+
enabled: bool = True
policy: str = "open" # "open" or "allowlist"
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs
-class SlackConfig(BaseModel):
+class SlackConfig(Base):
"""Slack channel configuration."""
+
enabled: bool = False
mode: str = "socket" # "socket" supported
webhook_path: str = "/slack/events"
@@ -134,16 +157,20 @@ class SlackConfig(BaseModel):
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
-class QQConfig(BaseModel):
+class QQConfig(Base):
"""QQ channel configuration using 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)
+ allow_from: list[str] = Field(
+ default_factory=list
+ ) # Allowed user openids (empty = public access)
-class ChannelsConfig(BaseModel):
+class ChannelsConfig(Base):
"""Configuration for chat channels."""
+
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
discord: DiscordConfig = Field(default_factory=DiscordConfig)
@@ -155,8 +182,9 @@ class ChannelsConfig(BaseModel):
qq: QQConfig = Field(default_factory=QQConfig)
-class AgentDefaults(BaseModel):
+class AgentDefaults(Base):
"""Default agent configuration."""
+
workspace: str = "~/.nanobot/workspace"
model: str = "anthropic/claude-opus-4-5"
max_tokens: int = 8192
@@ -165,20 +193,23 @@ class AgentDefaults(BaseModel):
memory_window: int = 50
-class AgentsConfig(BaseModel):
+class AgentsConfig(Base):
"""Agent configuration."""
+
defaults: AgentDefaults = Field(default_factory=AgentDefaults)
-class ProviderConfig(BaseModel):
+class ProviderConfig(Base):
"""LLM provider configuration."""
+
api_key: str = ""
api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
-class ProvidersConfig(BaseModel):
+class ProvidersConfig(Base):
"""Configuration for LLM providers."""
+
custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint
anthropic: ProviderConfig = Field(default_factory=ProviderConfig)
openai: ProviderConfig = Field(default_factory=ProviderConfig)
@@ -196,38 +227,44 @@ class ProvidersConfig(BaseModel):
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
-class GatewayConfig(BaseModel):
+class GatewayConfig(Base):
"""Gateway/server configuration."""
+
host: str = "0.0.0.0"
port: int = 18790
-class WebSearchConfig(BaseModel):
+class WebSearchConfig(Base):
"""Web search tool configuration."""
+
api_key: str = "" # Brave Search API key
max_results: int = 5
-class WebToolsConfig(BaseModel):
+class WebToolsConfig(Base):
"""Web tools configuration."""
+
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
-class ExecToolConfig(BaseModel):
+class ExecToolConfig(Base):
"""Shell exec tool configuration."""
+
timeout: int = 60
-class MCPServerConfig(BaseModel):
+class MCPServerConfig(Base):
"""MCP server connection configuration (stdio or HTTP)."""
+
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
-class ToolsConfig(BaseModel):
+class ToolsConfig(Base):
"""Tools configuration."""
+
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
@@ -236,20 +273,24 @@ class ToolsConfig(BaseModel):
class Config(BaseSettings):
"""Root configuration for nanobot."""
+
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)
-
+
@property
def workspace_path(self) -> Path:
"""Get expanded workspace path."""
return Path(self.agents.defaults.workspace).expanduser()
-
- def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]:
+
+ def _match_provider(
+ self, model: str | None = None
+ ) -> tuple["ProviderConfig | None", str | None]:
"""Match provider config and its registry name. Returns (config, spec_name)."""
from nanobot.providers.registry import PROVIDERS
+
model_lower = (model or self.agents.defaults.model).lower()
# Match by keyword (order follows PROVIDERS registry)
@@ -283,10 +324,11 @@ class Config(BaseSettings):
"""Get API key for the given model. Falls back to first available key."""
p = self.get_provider(model)
return p.api_key if p else None
-
+
def get_api_base(self, model: str | None = None) -> str | None:
"""Get API base URL for the given model. Applies default URLs for known gateways."""
from nanobot.providers.registry import find_by_name
+
p, name = self._match_provider(model)
if p and p.api_base:
return p.api_base
@@ -298,8 +340,5 @@ class Config(BaseSettings):
if spec and spec.is_gateway and spec.default_api_base:
return spec.default_api_base
return None
-
- model_config = ConfigDict(
- env_prefix="NANOBOT_",
- env_nested_delimiter="__"
- )
+
+ model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__")
From 941c3d98264303c7494b98c306c2696499bdc45a Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 17:34:24 +0000
Subject: [PATCH 151/506] style: restore single-line formatting for readability
---
nanobot/config/schema.py | 16 ++++------------
1 file changed, 4 insertions(+), 12 deletions(-)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 0786080..76ec74d 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -27,9 +27,7 @@ class TelegramConfig(Base):
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"
- )
+ proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
class FeishuConfig(Base):
@@ -86,9 +84,7 @@ class EmailConfig(Base):
from_address: str = ""
# Behavior
- auto_reply_enabled: bool = (
- True # If false, inbound email is read but no automatic reply is sent
- )
+ 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
@@ -163,9 +159,7 @@ class QQConfig(Base):
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)
+ allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access)
class ChannelsConfig(Base):
@@ -285,9 +279,7 @@ class Config(BaseSettings):
"""Get expanded workspace path."""
return Path(self.agents.defaults.workspace).expanduser()
- def _match_provider(
- self, model: str | None = None
- ) -> tuple["ProviderConfig | None", str | None]:
+ def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]:
"""Match provider config and its registry name. Returns (config, spec_name)."""
from nanobot.providers.registry import PROVIDERS
From aad1df5b9b13f8bd0e233af7cc26cc47e0615ff4 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 17:55:48 +0000
Subject: [PATCH 152/506] Simplify Docker Compose docs and remove fixed CLI
container name
---
README.md | 39 ++++++++++-----------------------------
docker-compose.yml | 1 -
2 files changed, 10 insertions(+), 30 deletions(-)
diff --git a/README.md b/README.md
index f5a92fd..96ff557 100644
--- a/README.md
+++ b/README.md
@@ -811,40 +811,21 @@ nanobot cron remove
> [!TIP]
> The `-v ~/.nanobot:/root/.nanobot` flag mounts your local config directory into the container, so your config and workspace persist across container restarts.
-### Using Docker Compose (Recommended)
-
-The easiest way to run nanobot with Docker:
+### Docker Compose
```bash
-# 1. Initialize config (first time only)
-docker compose run --rm nanobot-cli onboard
-
-# 2. Edit config to add API keys
-vim ~/.nanobot/config.json
-
-# 3. Start gateway service
-docker compose up -d nanobot-gateway
-
-# 4. Check logs
-docker compose logs -f nanobot-gateway
-
-# 5. Run CLI commands
-docker compose run --rm nanobot-cli status
-docker compose run --rm nanobot-cli agent -m "Hello!"
-
-# 6. Stop services
-docker compose down
+docker compose run --rm nanobot-cli onboard # first-time setup
+vim ~/.nanobot/config.json # add API keys
+docker compose up -d nanobot-gateway # start gateway
```
-**Features:**
-- ✅ Resource limits (1 CPU, 1GB memory)
-- ✅ Auto-restart on failure
-- ✅ Shared configuration using YAML anchors
-- ✅ Separate CLI profile for on-demand commands
+```bash
+docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
+docker compose logs -f nanobot-gateway # view logs
+docker compose down # stop
+```
-### Using Docker directly
-
-Build and run nanobot in a container:
+### Docker
```bash
# Build the image
diff --git a/docker-compose.yml b/docker-compose.yml
index 446f5e3..5c27f81 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -23,7 +23,6 @@ services:
memory: 256M
nanobot-cli:
- container_name: nanobot-cli
<<: *common-config
profiles:
- cli
From 05d06b1eb8a2492992da89f7dc0534df158cff4f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 17 Feb 2026 17:58:36 +0000
Subject: [PATCH 153/506] docs: update line count
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 96ff557..325aa64 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,689 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,696 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
From 831eb07945e0ee4cfb8bdfd927cf2e25cf7ed433 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Wed, 18 Feb 2026 02:00:30 +0800
Subject: [PATCH 154/506] docs: update security guideline
---
SECURITY.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/SECURITY.md b/SECURITY.md
index af3448c..405ce52 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -5,7 +5,7 @@
If you discover a security vulnerability in nanobot, please report it by:
1. **DO NOT** open a public GitHub issue
-2. Create a private security advisory on GitHub or contact the repository maintainers
+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
From 72db01db636c4ec2e63b50a7c79dfbdbf758b8a9 Mon Sep 17 00:00:00 2001
From: Hyudryu
Date: Tue, 17 Feb 2026 13:42:57 -0800
Subject: [PATCH 155/506] slack: Added replyInThread logic and custom react
emoji in config
---
nanobot/channels/slack.py | 6 ++++--
nanobot/config/schema.py | 2 ++
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index e5fff63..dca5055 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -152,13 +152,15 @@ class SlackChannel(BaseChannel):
text = self._strip_bot_mention(text)
- thread_ts = event.get("thread_ts") or event.get("ts")
+ 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="eyes",
+ name=self.config.react_emoji,
timestamp=event.get("ts"),
)
except Exception as e:
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 76ec74d..3cacbde 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -148,6 +148,8 @@ class SlackConfig(Base):
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)
From b161fa4f9a7521d9868f2bd83d240cc9a15dc21f Mon Sep 17 00:00:00 2001
From: Jeroen Evens
Date: Fri, 6 Feb 2026 18:38:18 +0100
Subject: [PATCH 156/506] [github] Add Github Copilot
---
nanobot/cli/commands.py | 4 +++-
nanobot/config/schema.py | 4 +++-
nanobot/providers/litellm_provider.py | 7 +++++++
3 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 5280d0f..ff3493a 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -892,7 +892,9 @@ def status():
p = getattr(config.providers, spec.name, None)
if p is None:
continue
- if spec.is_local:
+ if spec.is_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]")
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 76ec74d..99c659c 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -298,7 +298,9 @@ class Config(BaseSettings):
if spec.is_oauth:
continue
p = getattr(self.providers, spec.name, None)
- if p and p.api_key:
+ if p is None:
+ continue
+ if p.api_key:
return p, spec.name
return None, None
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 8cc4e35..43dfbb5 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -38,6 +38,9 @@ class LiteLLMProvider(LLMProvider):
# api_key / api_base are fallback for auto-detection.
self._gateway = find_gateway(provider_name, api_key, api_base)
+ # Detect GitHub Copilot (uses OAuth device flow, no API key)
+ self.is_github_copilot = "github_copilot" in default_model
+
# Configure environment variables
if api_key:
self._setup_env(api_key, api_base, default_model)
@@ -76,6 +79,10 @@ class LiteLLMProvider(LLMProvider):
def _resolve_model(self, model: str) -> str:
"""Resolve model name by applying provider/gateway prefixes."""
+ # GitHub Copilot models pass through directly
+ if self.is_github_copilot:
+ return model
+
if self._gateway:
# Gateway mode: apply gateway prefix, skip provider-specific prefixes
prefix = self._gateway.litellm_prefix
From 16127d49f98cccb47fdf99306f41c38934821bc9 Mon Sep 17 00:00:00 2001
From: Jeroen Evens
Date: Tue, 17 Feb 2026 22:50:39 +0100
Subject: [PATCH 157/506] [github] Fix Oauth login
---
nanobot/cli/commands.py | 112 +++++++++++++++++++-------
nanobot/providers/litellm_provider.py | 7 --
2 files changed, 82 insertions(+), 37 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index ff3493a..c1104da 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -915,40 +915,92 @@ app.add_typer(provider_app, name="provider")
@provider_app.command("login")
def provider_login(
- provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex')"),
+ provider: str = typer.Argument(..., help="OAuth provider to authenticate with (e.g., 'openai-codex', 'github-copilot')"),
):
"""Authenticate with an OAuth provider."""
- console.print(f"{__logo__} OAuth Login - {provider}\n")
+ from nanobot.providers.registry import PROVIDERS
- if provider == "openai-codex":
- try:
- from oauth_cli_kit import get_token, login_oauth_interactive
- token = None
- try:
- token = get_token()
- except Exception:
- token = None
- if not (token and token.access):
- console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]")
- console.print("A browser window may open for you to authenticate.\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("[green]✓ Successfully authenticated with OpenAI Codex![/green]")
- console.print(f"[dim]Account ID: {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)
- except Exception as e:
- console.print(f"[red]Authentication error: {e}[/red]")
- raise typer.Exit(1)
- else:
+ # Normalize: "github-copilot" → "github_copilot"
+ provider_key = provider.replace("-", "_")
+
+ # Validate against the registry — only OAuth providers support login
+ spec = None
+ for s in PROVIDERS:
+ if s.name == provider_key and s.is_oauth:
+ spec = s
+ break
+ if not spec:
+ oauth_names = [s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth]
console.print(f"[red]Unknown OAuth provider: {provider}[/red]")
- console.print("[yellow]Supported providers: openai-codex[/yellow]")
+ console.print(f"[yellow]Supported providers: {', '.join(oauth_names)}[/yellow]")
+ raise typer.Exit(1)
+
+ console.print(f"{__logo__} OAuth Login - {spec.display_name}\n")
+
+ if spec.name == "openai_codex":
+ _login_openai_codex()
+ elif spec.name == "github_copilot":
+ _login_github_copilot()
+
+
+def _login_openai_codex() -> None:
+ """Authenticate with OpenAI Codex via oauth_cli_kit."""
+ try:
+ from oauth_cli_kit import get_token, login_oauth_interactive
+ token = None
+ try:
+ token = get_token()
+ except Exception:
+ token = None
+ if not (token and token.access):
+ console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]")
+ console.print("A browser window may open for you to authenticate.\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("[green]✓ Successfully authenticated with OpenAI Codex![/green]")
+ console.print(f"[dim]Account ID: {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)
+ except Exception as e:
+ console.print(f"[red]Authentication error: {e}[/red]")
+ raise typer.Exit(1)
+
+
+def _login_github_copilot() -> None:
+ """Authenticate with GitHub Copilot via LiteLLM's device flow.
+
+ LiteLLM handles the full OAuth device flow (device code → poll → token
+ storage) internally when a github_copilot/ model is first called.
+ We trigger that flow by sending a minimal completion request.
+ """
+ import asyncio
+
+ console.print("[cyan]Starting GitHub Copilot device flow via LiteLLM...[/cyan]")
+ console.print("You will be prompted to visit a URL and enter a device code.\n")
+
+ async def _trigger_device_flow() -> None:
+ from litellm import acompletion
+ await acompletion(
+ model="github_copilot/gpt-4o",
+ messages=[{"role": "user", "content": "hi"}],
+ max_tokens=1,
+ )
+
+ try:
+ asyncio.run(_trigger_device_flow())
+ console.print("\n[green]✓ Successfully authenticated with GitHub Copilot![/green]")
+ except Exception as e:
+ error_msg = str(e)
+ # A successful device flow still returns a valid response;
+ # any exception here means the flow genuinely failed.
+ console.print(f"[red]Authentication error: {error_msg}[/red]")
+ console.print("[yellow]Ensure you have a GitHub Copilot subscription.[/yellow]")
raise typer.Exit(1)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 43dfbb5..8cc4e35 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -38,9 +38,6 @@ class LiteLLMProvider(LLMProvider):
# api_key / api_base are fallback for auto-detection.
self._gateway = find_gateway(provider_name, api_key, api_base)
- # Detect GitHub Copilot (uses OAuth device flow, no API key)
- self.is_github_copilot = "github_copilot" in default_model
-
# Configure environment variables
if api_key:
self._setup_env(api_key, api_base, default_model)
@@ -79,10 +76,6 @@ class LiteLLMProvider(LLMProvider):
def _resolve_model(self, model: str) -> str:
"""Resolve model name by applying provider/gateway prefixes."""
- # GitHub Copilot models pass through directly
- if self.is_github_copilot:
- return model
-
if self._gateway:
# Gateway mode: apply gateway prefix, skip provider-specific prefixes
prefix = self._gateway.litellm_prefix
From e2a0d639099372e4da89173a9a3ce5c76d3264ba Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 18 Feb 2026 02:39:15 +0000
Subject: [PATCH 158/506] feat: add custom provider with direct
openai-compatible support
---
README.md | 6 ++--
nanobot/cli/commands.py | 13 ++++++--
nanobot/providers/custom_provider.py | 47 ++++++++++++++++++++++++++++
nanobot/providers/registry.py | 15 +++++----
4 files changed, 68 insertions(+), 13 deletions(-)
create mode 100644 nanobot/providers/custom_provider.py
diff --git a/README.md b/README.md
index 325aa64..30210a7 100644
--- a/README.md
+++ b/README.md
@@ -574,7 +574,7 @@ Config file: `~/.nanobot/config.json`
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
-| `custom` | Any OpenAI-compatible endpoint | — |
+| `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | — |
| `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
@@ -623,7 +623,7 @@ nanobot agent -m "Hello!"
Custom Provider (Any OpenAI-compatible API)
-If your provider is not listed above but exposes an **OpenAI-compatible API** (e.g. Together AI, Fireworks, Azure OpenAI, self-hosted endpoints), use the `custom` provider:
+Connects directly to any OpenAI-compatible endpoint — LM Studio, llama.cpp, Together AI, Fireworks, Azure OpenAI, or any self-hosted server. Bypasses LiteLLM; model name is passed as-is.
```json
{
@@ -641,7 +641,7 @@ If your provider is not listed above but exposes an **OpenAI-compatible API** (e
}
```
-> The `custom` provider routes through LiteLLM's OpenAI-compatible path. It works with any endpoint that follows the OpenAI chat completions API format. The model name is passed directly to the endpoint without any prefix.
+> For local servers that don't require a key, set `apiKey` to any non-empty string (e.g. `"no-key"`).
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 5280d0f..6b245bf 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -280,18 +280,27 @@ This file stores important information that should persist across sessions.
def _make_provider(config: Config):
- """Create LiteLLMProvider from config. Exits if no API key found."""
+ """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
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
p = config.get_provider(model)
- # OpenAI Codex (OAuth): don't route via LiteLLM; use the dedicated implementation.
+ # OpenAI Codex (OAuth)
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
return OpenAICodexProvider(default_model=model)
+ # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
+ if provider_name == "custom":
+ return CustomProvider(
+ api_key=p.api_key if p else "no-key",
+ api_base=config.get_api_base(model) or "http://localhost:8000/v1",
+ default_model=model,
+ )
+
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):
diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py
new file mode 100644
index 0000000..f190ccf
--- /dev/null
+++ b/nanobot/providers/custom_provider.py
@@ -0,0 +1,47 @@
+"""Direct OpenAI-compatible provider — bypasses LiteLLM."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import json_repair
+from openai import AsyncOpenAI
+
+from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
+
+
+class CustomProvider(LLMProvider):
+
+ def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"):
+ super().__init__(api_key, api_base)
+ self.default_model = default_model
+ self._client = AsyncOpenAI(api_key=api_key, base_url=api_base)
+
+ async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
+ model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7) -> LLMResponse:
+ kwargs: dict[str, Any] = {"model": model or self.default_model, "messages": messages,
+ "max_tokens": max(1, max_tokens), "temperature": temperature}
+ if tools:
+ kwargs.update(tools=tools, tool_choice="auto")
+ try:
+ return self._parse(await self._client.chat.completions.create(**kwargs))
+ except Exception as e:
+ return LLMResponse(content=f"Error: {e}", finish_reason="error")
+
+ def _parse(self, response: Any) -> LLMResponse:
+ choice = response.choices[0]
+ msg = choice.message
+ tool_calls = [
+ ToolCallRequest(id=tc.id, name=tc.function.name,
+ arguments=json_repair.loads(tc.function.arguments) if isinstance(tc.function.arguments, str) else tc.function.arguments)
+ for tc in (msg.tool_calls or [])
+ ]
+ u = response.usage
+ return LLMResponse(
+ content=msg.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop",
+ usage={"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens, "total_tokens": u.total_tokens} if u else {},
+ reasoning_content=getattr(msg, "reasoning_content", None),
+ )
+
+ def get_default_model(self) -> str:
+ return self.default_model
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 1e760d6..7d951fa 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -54,6 +54,9 @@ class ProviderSpec:
# OAuth-based providers (e.g., OpenAI Codex) don't use API keys
is_oauth: bool = False # if True, uses OAuth flow instead of API key
+ # Direct providers bypass LiteLLM entirely (e.g., CustomProvider)
+ is_direct: bool = False
+
@property
def label(self) -> str:
return self.display_name or self.name.title()
@@ -65,18 +68,14 @@ class ProviderSpec:
PROVIDERS: tuple[ProviderSpec, ...] = (
- # === Custom (user-provided OpenAI-compatible endpoint) =================
- # No auto-detection — only activates when user explicitly configures "custom".
-
+ # === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ======
ProviderSpec(
name="custom",
keywords=(),
- env_key="OPENAI_API_KEY",
+ env_key="",
display_name="Custom",
- litellm_prefix="openai",
- skip_prefixes=("openai/",),
- is_gateway=True,
- strip_model_prefix=True,
+ litellm_prefix="",
+ is_direct=True,
),
# === Gateways (detected by api_key / api_base, not model name) =========
From d54831a35f25c09450054a451fef3f6a4cda7941 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 18 Feb 2026 03:09:09 +0000
Subject: [PATCH 159/506] feat: add github copilot oauth login and improve
provider status display
---
README.md | 2 +-
nanobot/cli/commands.py | 96 +++++++++++++++++-----------------------
nanobot/config/schema.py | 4 +-
3 files changed, 42 insertions(+), 60 deletions(-)
diff --git a/README.md b/README.md
index 30210a7..03789de 100644
--- a/README.md
+++ b/README.md
@@ -588,7 +588,7 @@ Config file: `~/.nanobot/config.json`
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` |
-| `github_copilot` | LLM (GitHub Copilot, OAuth) | Requires [GitHub Copilot](https://github.com/features/copilot) subscription |
+| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
OpenAI Codex (OAuth)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 5efc297..8e17139 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -922,48 +922,50 @@ provider_app = typer.Typer(help="Manage providers")
app.add_typer(provider_app, name="provider")
+_LOGIN_HANDLERS: dict[str, callable] = {}
+
+
+def _register_login(name: str):
+ 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 to authenticate with (e.g., 'openai-codex', 'github-copilot')"),
+ provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
):
"""Authenticate with an OAuth provider."""
from nanobot.providers.registry import PROVIDERS
- # Normalize: "github-copilot" → "github_copilot"
- provider_key = provider.replace("-", "_")
-
- # Validate against the registry — only OAuth providers support login
- spec = None
- for s in PROVIDERS:
- if s.name == provider_key and s.is_oauth:
- spec = s
- break
+ key = provider.replace("-", "_")
+ spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
if not spec:
- oauth_names = [s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth]
- console.print(f"[red]Unknown OAuth provider: {provider}[/red]")
- console.print(f"[yellow]Supported providers: {', '.join(oauth_names)}[/yellow]")
+ 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)
- console.print(f"{__logo__} OAuth Login - {spec.display_name}\n")
+ handler = _LOGIN_HANDLERS.get(spec.name)
+ if not handler:
+ console.print(f"[red]Login not implemented for {spec.label}[/red]")
+ raise typer.Exit(1)
- if spec.name == "openai_codex":
- _login_openai_codex()
- elif spec.name == "github_copilot":
- _login_github_copilot()
+ console.print(f"{__logo__} OAuth Login - {spec.label}\n")
+ handler()
+@_register_login("openai_codex")
def _login_openai_codex() -> None:
- """Authenticate with OpenAI Codex via oauth_cli_kit."""
try:
from oauth_cli_kit import get_token, login_oauth_interactive
token = None
try:
token = get_token()
except Exception:
- token = None
+ pass
if not (token and token.access):
- console.print("[cyan]No valid token found. Starting interactive OAuth login...[/cyan]")
- console.print("A browser window may open for you to authenticate.\n")
+ 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),
@@ -971,47 +973,29 @@ def _login_openai_codex() -> None:
if not (token and token.access):
console.print("[red]✗ Authentication failed[/red]")
raise typer.Exit(1)
- console.print("[green]✓ Successfully authenticated with OpenAI Codex![/green]")
- console.print(f"[dim]Account ID: {token.account_id}[/dim]")
+ 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:
+ 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)
-def _login_github_copilot() -> None:
- """Authenticate with GitHub Copilot via LiteLLM's device flow.
-
- LiteLLM handles the full OAuth device flow (device code → poll → token
- storage) internally when a github_copilot/ model is first called.
- We trigger that flow by sending a minimal completion request.
- """
- import asyncio
-
- console.print("[cyan]Starting GitHub Copilot device flow via LiteLLM...[/cyan]")
- console.print("You will be prompted to visit a URL and enter a device code.\n")
-
- async def _trigger_device_flow() -> None:
- from litellm import acompletion
- await acompletion(
- model="github_copilot/gpt-4o",
- messages=[{"role": "user", "content": "hi"}],
- max_tokens=1,
- )
-
- try:
- asyncio.run(_trigger_device_flow())
- console.print("\n[green]✓ Successfully authenticated with GitHub Copilot![/green]")
- except Exception as e:
- error_msg = str(e)
- # A successful device flow still returns a valid response;
- # any exception here means the flow genuinely failed.
- console.print(f"[red]Authentication error: {error_msg}[/red]")
- console.print("[yellow]Ensure you have a GitHub Copilot subscription.[/yellow]")
- raise typer.Exit(1)
-
-
if __name__ == "__main__":
app()
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index fe909ef..3cacbde 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -300,9 +300,7 @@ class Config(BaseSettings):
if spec.is_oauth:
continue
p = getattr(self.providers, spec.name, None)
- if p is None:
- continue
- if p.api_key:
+ if p and p.api_key:
return p, spec.name
return None, None
From 80a5a8c983de351b62060b9d1bc3d40d1a122228 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 18 Feb 2026 03:52:53 +0000
Subject: [PATCH 160/506] feat: add siliconflow provider support
---
README.md | 1 +
nanobot/providers/registry.py | 9 +++------
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 03789de..dfe799a 100644
--- a/README.md
+++ b/README.md
@@ -583,6 +583,7 @@ Config file: `~/.nanobot/config.json`
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
+| `siliconflow` | LLM (SiliconFlow/硅基流动, API gateway) | [siliconflow.cn](https://siliconflow.cn) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index d267069..49b735c 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -119,16 +119,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
model_overrides=(),
),
- # SiliconFlow (硅基流动): OpenAI-compatible gateway hosting multiple models.
- # strip_model_prefix=False: SiliconFlow model names include org prefix
- # (e.g. "Qwen/Qwen2.5-14B-Instruct", "deepseek-ai/DeepSeek-V3")
- # which is part of the model ID and must NOT be stripped.
+ # SiliconFlow (硅基流动): OpenAI-compatible gateway, model names keep org prefix
ProviderSpec(
name="siliconflow",
keywords=("siliconflow",),
- env_key="OPENAI_API_KEY", # OpenAI-compatible
+ env_key="OPENAI_API_KEY",
display_name="SiliconFlow",
- litellm_prefix="openai", # → openai/{model}
+ litellm_prefix="openai",
skip_prefixes=(),
env_extras=(),
is_gateway=True,
From 27a131830f31ae8560aa4f0f44fad577d6e940c4 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 18 Feb 2026 05:09:57 +0000
Subject: [PATCH 161/506] refine: migrate legacy sessions on load and simplify
get_history
---
nanobot/session/manager.py | 39 +++++++++++++-------------------------
1 file changed, 13 insertions(+), 26 deletions(-)
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index e2d8e5c..752fce4 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -42,30 +42,15 @@ class Session:
self.updated_at = datetime.now()
def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
- """
- Get recent messages in LLM format.
-
- Preserves tool metadata for replay/debugging fidelity.
- """
- history: list[dict[str, Any]] = []
- for msg in self.messages[-max_messages:]:
- llm_msg: dict[str, Any] = {
- "role": msg["role"],
- "content": msg.get("content", ""),
- }
-
- if msg["role"] == "assistant" and "tool_calls" in msg:
- llm_msg["tool_calls"] = msg["tool_calls"]
-
- if msg["role"] == "tool":
- if "tool_call_id" in msg:
- llm_msg["tool_call_id"] = msg["tool_call_id"]
- if "name" in msg:
- llm_msg["name"] = msg["name"]
-
- history.append(llm_msg)
-
- return history
+ """Get recent messages in LLM format, preserving tool metadata."""
+ out: list[dict[str, Any]] = []
+ for m in self.messages[-max_messages:]:
+ entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")}
+ for k in ("tool_calls", "tool_call_id", "name"):
+ if k in m:
+ entry[k] = m[k]
+ out.append(entry)
+ return out
def clear(self) -> None:
"""Clear all messages and reset session to initial state."""
@@ -93,7 +78,7 @@ class SessionManager:
return self.sessions_dir / f"{safe_key}.jsonl"
def _get_legacy_session_path(self, key: str) -> Path:
- """Get the legacy global session path for backward compatibility."""
+ """Legacy global session path (~/.nanobot/sessions/)."""
safe_key = safe_filename(key.replace(":", "_"))
return self.legacy_sessions_dir / f"{safe_key}.jsonl"
@@ -123,7 +108,9 @@ class SessionManager:
if not path.exists():
legacy_path = self._get_legacy_session_path(key)
if legacy_path.exists():
- path = legacy_path
+ import shutil
+ shutil.move(str(legacy_path), str(path))
+ logger.info(f"Migrated session {key} from legacy path")
if not path.exists():
return None
From e44f14379a289f900556fa3d6f255f446aee634f Mon Sep 17 00:00:00 2001
From: Ivan
Date: Wed, 18 Feb 2026 11:57:58 +0300
Subject: [PATCH 162/506] fix: sanitize messages and ensure 'content' for
strict LLM providers
- Strip non-standard keys like 'reasoning_content' before sending to LLM
- Always include 'content' key in assistant messages (required by StepFun)
- Add _sanitize_messages to LiteLLMProvider to prevent 400 BadRequest errors
---
nanobot/agent/context.py | 6 +++---
nanobot/providers/litellm_provider.py | 26 +++++++++++++++++++++++++-
2 files changed, 28 insertions(+), 4 deletions(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index cfd6318..458016e 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -227,9 +227,9 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
"""
msg: dict[str, Any] = {"role": "assistant"}
- # Omit empty content — some backends reject empty text blocks
- if content:
- msg["content"] = content
+ # Always include content — some providers (e.g. StepFun) reject
+ # assistant messages that omit the key entirely.
+ msg["content"] = content
if tool_calls:
msg["tool_calls"] = tool_calls
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 8cc4e35..58acf95 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -12,6 +12,12 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.registry import find_by_model, find_gateway
+# Keys that are part of the OpenAI chat-completion message schema.
+# Anything else (e.g. reasoning_content, timestamp) is stripped before sending
+# to avoid "Unrecognized chat message" errors from strict providers like StepFun.
+_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"})
+
+
class LiteLLMProvider(LLMProvider):
"""
LLM provider using LiteLLM for multi-provider support.
@@ -103,6 +109,24 @@ class LiteLLMProvider(LLMProvider):
kwargs.update(overrides)
return
+ @staticmethod
+ def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Strip non-standard keys from messages for strict providers.
+
+ Some providers (e.g. StepFun via OpenRouter) reject messages that
+ contain extra keys like ``reasoning_content``. This method keeps
+ only the keys defined in the OpenAI chat-completion schema and
+ ensures every assistant message has a ``content`` key.
+ """
+ sanitized = []
+ for msg in messages:
+ clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS}
+ # Strict providers require "content" even when assistant only has tool_calls
+ if clean.get("role") == "assistant" and "content" not in clean:
+ clean["content"] = None
+ sanitized.append(clean)
+ return sanitized
+
async def chat(
self,
messages: list[dict[str, Any]],
@@ -132,7 +156,7 @@ class LiteLLMProvider(LLMProvider):
kwargs: dict[str, Any] = {
"model": model,
- "messages": messages,
+ "messages": self._sanitize_messages(messages),
"max_tokens": max_tokens,
"temperature": temperature,
}
From 715b2db24b312cd81d6601651cc17f7a523d722b Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 18 Feb 2026 14:23:51 +0000
Subject: [PATCH 163/506] feat: stream intermediate progress to user during
tool execution
---
README.md | 2 +-
nanobot/agent/context.py | 2 +-
nanobot/agent/loop.py | 57 +++++++++++++++++++++++++++++++++++-----
nanobot/cli/commands.py | 7 +++--
4 files changed, 57 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index dfe799a..fcbc878 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,696 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,761 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index cfd6318..876d43d 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -105,7 +105,7 @@ IMPORTANT: When responding to direct questions or conversations, reply directly
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
-Always be helpful, accurate, and concise. When using tools, think step by step: what you know, what you need, and why you chose this tool.
+Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language).
When remembering something important, write to {workspace_path}/memory/MEMORY.md
To recall past events, grep {workspace_path}/memory/HISTORY.md"""
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 6342f56..e5a5183 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -5,7 +5,8 @@ from contextlib import AsyncExitStack
import json
import json_repair
from pathlib import Path
-from typing import Any
+import re
+from typing import Any, Awaitable, Callable
from loguru import logger
@@ -146,12 +147,34 @@ class AgentLoop:
if isinstance(cron_tool, CronTool):
cron_tool.set_context(channel, chat_id)
- async def _run_agent_loop(self, initial_messages: list[dict]) -> tuple[str | None, list[str]]:
+ @staticmethod
+ def _strip_think(text: str | None) -> str | None:
+ """Remove … blocks that some models embed in content."""
+ if not text:
+ return None
+ return re.sub(r"[\s\S]*?", "", text).strip() or None
+
+ @staticmethod
+ def _tool_hint(tool_calls: list) -> str:
+ """Format tool calls as concise hint, e.g. '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[[str], Awaitable[None]] | None = None,
+ ) -> tuple[str | None, list[str]]:
"""
Run the agent iteration loop.
Args:
initial_messages: Starting messages for the LLM conversation.
+ on_progress: Optional callback to push intermediate content to the user.
Returns:
Tuple of (final_content, list_of_tools_used).
@@ -173,6 +196,10 @@ class AgentLoop:
)
if response.has_tool_calls:
+ if on_progress:
+ clean = self._strip_think(response.content)
+ await on_progress(clean or self._tool_hint(response.tool_calls))
+
tool_call_dicts = [
{
"id": tc.id,
@@ -197,9 +224,8 @@ class AgentLoop:
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
- messages.append({"role": "user", "content": "Reflect on the results and decide next steps."})
else:
- final_content = response.content
+ final_content = self._strip_think(response.content)
break
return final_content, tools_used
@@ -244,13 +270,19 @@ class AgentLoop:
self._running = False
logger.info("Agent loop stopping")
- async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None:
+ async def _process_message(
+ self,
+ msg: InboundMessage,
+ session_key: str | None = None,
+ on_progress: Callable[[str], Awaitable[None]] | None = None,
+ ) -> OutboundMessage | None:
"""
Process a single inbound message.
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
+ on_progress: Optional callback for intermediate output (defaults to bus publish).
Returns:
The response message, or None if no response needed.
@@ -297,7 +329,16 @@ class AgentLoop:
channel=msg.channel,
chat_id=msg.chat_id,
)
- final_content, tools_used = await self._run_agent_loop(initial_messages)
+
+ async def _bus_progress(content: str) -> None:
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel, chat_id=msg.chat_id, content=content,
+ metadata=msg.metadata or {},
+ ))
+
+ final_content, tools_used = await self._run_agent_loop(
+ initial_messages, on_progress=on_progress or _bus_progress,
+ )
if final_content is None:
final_content = "I've completed processing but have no response to give."
@@ -451,6 +492,7 @@ Respond with ONLY valid JSON, no markdown fences."""
session_key: str = "cli:direct",
channel: str = "cli",
chat_id: str = "direct",
+ on_progress: Callable[[str], Awaitable[None]] | None = None,
) -> str:
"""
Process a message directly (for CLI or cron usage).
@@ -460,6 +502,7 @@ Respond with ONLY valid JSON, no markdown fences."""
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
+ on_progress: Optional callback for intermediate output.
Returns:
The agent's response.
@@ -472,5 +515,5 @@ Respond with ONLY valid JSON, no markdown fences."""
content=content
)
- response = await self._process_message(msg, session_key=session_key)
+ response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 8e17139..2f4ba7b 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -494,11 +494,14 @@ def agent(
# Animated spinner is safe to use with prompt_toolkit input handling
return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
+ async def _cli_progress(content: str) -> None:
+ console.print(f" [dim]↳ {content}[/dim]")
+
if message:
# Single message mode
async def run_once():
with _thinking_ctx():
- response = await agent_loop.process_direct(message, session_id)
+ 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()
@@ -531,7 +534,7 @@ def agent(
break
with _thinking_ctx():
- response = await agent_loop.process_direct(user_input, session_id)
+ response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress)
_print_agent_response(response, render_markdown=markdown)
except KeyboardInterrupt:
_restore_terminal()
From b14d4711c0cbff34d030b47018bf1bcbf5e33f26 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 18 Feb 2026 14:31:26 +0000
Subject: [PATCH 164/506] release: v0.1.4
---
nanobot/__init__.py | 2 +-
pyproject.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/nanobot/__init__.py b/nanobot/__init__.py
index ee0445b..a68777c 100644
--- a/nanobot/__init__.py
+++ b/nanobot/__init__.py
@@ -2,5 +2,5 @@
nanobot - A lightweight AI agent framework
"""
-__version__ = "0.1.0"
+__version__ = "0.1.4"
__logo__ = "🐈"
diff --git a/pyproject.toml b/pyproject.toml
index 6261653..bbd6feb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
-version = "0.1.3.post7"
+version = "0.1.4"
description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11"
license = {text = "MIT"}
From 1f1f5b2d275c177e457efc0cafed650f2f707bf7 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 18 Feb 2026 14:41:13 +0000
Subject: [PATCH 165/506] docs: update v0.1.4 release news
---
README.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/README.md b/README.md
index fcbc878..7fad9ce 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
## 📢 News
+- **2026-02-17** 🎉 Released v0.1.4 — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details.
@@ -29,6 +30,10 @@
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
+
+
+Earlier news
+
- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
@@ -36,6 +41,8 @@
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
+
+
## Key Features of nanobot:
🪶 **Ultra-Lightweight**: Just ~4,000 lines of core agent code — 99% smaller than Clawdbot.
From 8de36d398fb017af42ee831c653a9e8c5cd90783 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Wed, 18 Feb 2026 23:09:55 +0800
Subject: [PATCH 166/506] docs: update news about release information
---
README.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 7fad9ce..a474367 100644
--- a/README.md
+++ b/README.md
@@ -20,24 +20,24 @@
## 📢 News
-- **2026-02-17** 🎉 Released v0.1.4 — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
+- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details.
-- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
+- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
-- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
+- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
Earlier news
-- **2026-02-07** 🚀 Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
+- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
-- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
+- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
From c5b4331e692c09bb285a5c8e3d811dbfca52b273 Mon Sep 17 00:00:00 2001
From: dxtime
Date: Thu, 19 Feb 2026 01:21:17 +0800
Subject: [PATCH 167/506] feature: Added custom headers for MCP Auth use.
---
nanobot/agent/tools/mcp.py | 18 +++++++++++++++---
nanobot/config/schema.py | 1 +
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index 1c8eac4..4d5c053 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -59,9 +59,21 @@ async def connect_mcp_servers(
read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url:
from mcp.client.streamable_http import streamable_http_client
- read, write, _ = await stack.enter_async_context(
- streamable_http_client(cfg.url)
- )
+ import httpx
+ if cfg.headers:
+ http_client = await stack.enter_async_context(
+ httpx.AsyncClient(
+ headers=cfg.headers,
+ follow_redirects=True
+ )
+ )
+ read, write, _ = await stack.enter_async_context(
+ streamable_http_client(cfg.url, http_client=http_client)
+ )
+ else:
+ read, write, _ = await stack.enter_async_context(
+ streamable_http_client(cfg.url)
+ )
else:
logger.warning(f"MCP server '{name}': no command or url configured, skipping")
continue
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index ce9634c..e404d3c 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -257,6 +257,7 @@ class MCPServerConfig(Base):
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
class ToolsConfig(Base):
From 4a85cd9a1102871aa900b906ffb8ca4c89d206d0 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 17 Feb 2026 13:18:43 +0100
Subject: [PATCH 168/506] fix(cron): add service-layer timezone validation
Adds `_validate_schedule_for_add()` to `CronService.add_job` so that
invalid or misplaced `tz` values are rejected before a job is persisted,
regardless of which caller (CLI, tool, etc.) invoked the service.
Surfaces the resulting `ValueError` in `nanobot cron add` via a
`try/except` so the CLI exits cleanly with a readable error message.
Co-Authored-By: Claude Sonnet 4.6
---
nanobot/cli/commands.py | 22 +++++++++++++---------
nanobot/cron/service.py | 15 +++++++++++++++
tests/test_cron_commands.py | 29 +++++++++++++++++++++++++++++
tests/test_cron_service.py | 30 ++++++++++++++++++++++++++++++
4 files changed, 87 insertions(+), 9 deletions(-)
create mode 100644 tests/test_cron_commands.py
create mode 100644 tests/test_cron_service.py
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index b61d9aa..668fcb5 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -787,15 +787,19 @@ def cron_add(
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
- job = service.add_job(
- name=name,
- schedule=schedule,
- message=message,
- deliver=deliver,
- to=to,
- channel=channel,
- )
-
+ try:
+ job = service.add_job(
+ name=name,
+ schedule=schedule,
+ message=message,
+ 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})")
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index 14666e8..7ae1153 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -45,6 +45,20 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None:
return None
+def _validate_schedule_for_add(schedule: CronSchedule) -> None:
+ """Validate schedule fields that would otherwise create non-runnable jobs."""
+ 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
+
+
class CronService:
"""Service for managing and executing scheduled jobs."""
@@ -272,6 +286,7 @@ class CronService:
) -> CronJob:
"""Add a new job."""
store = self._load_store()
+ _validate_schedule_for_add(schedule)
now = _now_ms()
job = CronJob(
diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py
new file mode 100644
index 0000000..bce1ef5
--- /dev/null
+++ b/tests/test_cron_commands.py
@@ -0,0 +1,29 @@
+from typer.testing import CliRunner
+
+from nanobot.cli.commands import app
+
+runner = CliRunner()
+
+
+def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None:
+ monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path)
+
+ result = runner.invoke(
+ app,
+ [
+ "cron",
+ "add",
+ "--name",
+ "demo",
+ "--message",
+ "hello",
+ "--cron",
+ "0 9 * * *",
+ "--tz",
+ "America/Vancovuer",
+ ],
+ )
+
+ assert result.exit_code == 1
+ assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout
+ assert not (tmp_path / "cron" / "jobs.json").exists()
diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py
new file mode 100644
index 0000000..07e990a
--- /dev/null
+++ b/tests/test_cron_service.py
@@ -0,0 +1,30 @@
+import pytest
+
+from nanobot.cron.service import CronService
+from nanobot.cron.types import CronSchedule
+
+
+def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
+ service = CronService(tmp_path / "cron" / "jobs.json")
+
+ with pytest.raises(ValueError, match="unknown timezone 'America/Vancovuer'"):
+ service.add_job(
+ name="tz typo",
+ schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancovuer"),
+ message="hello",
+ )
+
+ assert service.list_jobs(include_disabled=True) == []
+
+
+def test_add_job_accepts_valid_timezone(tmp_path) -> None:
+ service = CronService(tmp_path / "cron" / "jobs.json")
+
+ job = service.add_job(
+ name="tz ok",
+ schedule=CronSchedule(kind="cron", expr="0 9 * * *", tz="America/Vancouver"),
+ message="hello",
+ )
+
+ assert job.schedule.tz == "America/Vancouver"
+ assert job.state.next_run_at_ms is not None
From 166351799894d2159465ad7b3753ece78b977cec Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 19 Feb 2026 03:00:44 +0800
Subject: [PATCH 169/506] feat: Add VolcEngine LLM provider support
- Add VolcEngine ProviderSpec entry in registry.py
- Add volcengine to ProvidersConfig class in schema.py
- Update model providers table in README.md
- Add description about VolcEngine coding plan endpoint
---
README.md | 2 ++
nanobot/config/schema.py | 1 +
nanobot/providers/registry.py | 18 ++++++++++++++++++
3 files changed, 21 insertions(+)
diff --git a/README.md b/README.md
index a474367..94cdc88 100644
--- a/README.md
+++ b/README.md
@@ -578,6 +578,7 @@ Config file: `~/.nanobot/config.json`
> - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
+> - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config.
| Provider | Purpose | Get API Key |
|----------|---------|-------------|
@@ -591,6 +592,7 @@ Config file: `~/.nanobot/config.json`
| `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `siliconflow` | LLM (SiliconFlow/硅基流动, API gateway) | [siliconflow.cn](https://siliconflow.cn) |
+| `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index ce9634c..0d0c68f 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -220,6 +220,7 @@ class ProvidersConfig(Base):
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)
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 49b735c..e44720a 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -137,6 +137,24 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
model_overrides=(),
),
+ # VolcEngine (火山引擎): OpenAI-compatible gateway
+ ProviderSpec(
+ name="volcengine",
+ keywords=("volcengine", "volces", "ark"),
+ env_key="OPENAI_API_KEY",
+ display_name="VolcEngine",
+ litellm_prefix="openai",
+ skip_prefixes=(),
+ env_extras=(),
+ is_gateway=True,
+ is_local=False,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="volces",
+ default_api_base="https://ark.cn-beijing.volces.com/api/v3",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
+
# === Standard providers (matched by model-name keywords) ===============
# Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed.
From c865b293a9a772c000eed0cda63eecbd2efa472e Mon Sep 17 00:00:00 2001
From: Darye <54469750+DaryeDev@users.noreply.github.com>
Date: Wed, 18 Feb 2026 20:18:27 +0100
Subject: [PATCH 170/506] feat: enhance message context handling by adding
message_id parameter
---
nanobot/agent/loop.py | 8 ++++----
nanobot/agent/tools/message.py | 14 +++++++++++---
2 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index e5a5183..7855297 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -133,11 +133,11 @@ class AgentLoop:
await self._mcp_stack.__aenter__()
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
- def _set_tool_context(self, channel: str, chat_id: str) -> None:
+ def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Update context for all tools that need routing info."""
if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool):
- message_tool.set_context(channel, chat_id)
+ message_tool.set_context(channel, chat_id, message_id)
if spawn_tool := self.tools.get("spawn"):
if isinstance(spawn_tool, SpawnTool):
@@ -321,7 +321,7 @@ class AgentLoop:
if len(session.messages) > self.memory_window:
asyncio.create_task(self._consolidate_memory(session))
- self._set_tool_context(msg.channel, msg.chat_id)
+ self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
initial_messages = self.context.build_messages(
history=session.get_history(max_messages=self.memory_window),
current_message=msg.content,
@@ -379,7 +379,7 @@ class AgentLoop:
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
- self._set_tool_context(origin_channel, origin_chat_id)
+ self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id"))
initial_messages = self.context.build_messages(
history=session.get_history(max_messages=self.memory_window),
current_message=msg.content,
diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py
index 3853725..10947c4 100644
--- a/nanobot/agent/tools/message.py
+++ b/nanobot/agent/tools/message.py
@@ -13,16 +13,19 @@ class MessageTool(Tool):
self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
default_channel: str = "",
- default_chat_id: 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
- def set_context(self, channel: str, chat_id: str) -> None:
+ 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."""
@@ -67,11 +70,13 @@ class MessageTool(Tool):
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"
@@ -83,7 +88,10 @@ class MessageTool(Tool):
channel=channel,
chat_id=chat_id,
content=content,
- media=media or []
+ media=media or [],
+ metadata={
+ "message_id": message_id,
+ }
)
try:
From 3ac55130042ad7b98a2416ef3802bc1a576df248 Mon Sep 17 00:00:00 2001
From: Darye <54469750+DaryeDev@users.noreply.github.com>
Date: Wed, 18 Feb 2026 20:27:48 +0100
Subject: [PATCH 171/506] If given a message_id to telegram provider send, the
bot will try to reply to that message
---
nanobot/channels/telegram.py | 36 +++++++++++++++++++++++++++++++-----
1 file changed, 31 insertions(+), 5 deletions(-)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 39924b3..3a90d42 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import re
from loguru import logger
-from telegram import BotCommand, Update
+from telegram import BotCommand, Update, ReplyParameters
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
from telegram.request import HTTPXRequest
@@ -224,6 +224,15 @@ class TelegramChannel(BaseChannel):
logger.error(f"Invalid chat_id: {msg.chat_id}")
return
+ # Build reply parameters (Will reply to the message if it exists)
+ reply_to_message_id = msg.metadata.get("message_id")
+ reply_params = None
+ 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:
@@ -235,22 +244,39 @@ class TelegramChannel(BaseChannel):
}.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})
+ await sender(
+ chat_id=chat_id,
+ **{param: f},
+ reply_parameters=reply_params
+ )
except Exception as e:
filename = media_path.rsplit("/", 1)[-1]
logger.error(f"Failed to send media {media_path}: {e}")
- await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]")
+ 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")
+ 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(f"HTML parse failed, falling back to plain text: {e}")
try:
- await self._app.bot.send_message(chat_id=chat_id, text=chunk)
+ await self._app.bot.send_message(
+ chat_id=chat_id,
+ text=chunk,
+ reply_parameters=reply_params
+ )
except Exception as e2:
logger.error(f"Error sending Telegram message: {e2}")
From 536ed60a05fcd1510d17e61d1e30a6a926f5d319 Mon Sep 17 00:00:00 2001
From: ruby childs
Date: Wed, 18 Feb 2026 16:39:06 -0500
Subject: [PATCH 172/506] Fix safety guard false positive on 'format' in URLs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The deny pattern `\b(format|mkfs|diskpart)\b` incorrectly blocked
commands containing "format" inside URLs (e.g. `curl https://wttr.in?format=3`)
because `\b` fires at the boundary between `?` (non-word) and `f` (word).
Split into two patterns:
- `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone
command (start of line or after shell operators)
- `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive)
Co-Authored-By: Claude Opus 4.6
---
nanobot/agent/tools/shell.py | 3 +-
tests/test_exec_curl.py | 97 ++++++++++++++++++++++++++++++++++++
2 files changed, 99 insertions(+), 1 deletion(-)
create mode 100644 tests/test_exec_curl.py
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index 18eff64..9c6f1f6 100644
--- a/nanobot/agent/tools/shell.py
+++ b/nanobot/agent/tools/shell.py
@@ -26,7 +26,8 @@ class ExecTool(Tool):
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"\b(format|mkfs|diskpart)\b", # disk operations
+ 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
diff --git a/tests/test_exec_curl.py b/tests/test_exec_curl.py
new file mode 100644
index 0000000..2f53fcf
--- /dev/null
+++ b/tests/test_exec_curl.py
@@ -0,0 +1,97 @@
+r"""Tests for ExecTool safety guard — format pattern false positive.
+
+The old deny pattern `\b(format|mkfs|diskpart)\b` matched "format" inside
+URLs (e.g. `curl https://wttr.in?format=3`) because `?` is a non-word
+character, so `\b` fires between `?` and `f`.
+
+The fix splits the pattern:
+ - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command
+ - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive)
+"""
+
+import re
+
+import pytest
+
+from nanobot.agent.tools.shell import ExecTool
+
+
+# --- Guard regression: "format" in URLs must not be blocked ---
+
+
+@pytest.mark.asyncio
+async def test_curl_with_format_in_url_not_blocked():
+ """curl with ?format= in URL should NOT be blocked by the guard."""
+ tool = ExecTool(working_dir="/tmp")
+ result = await tool.execute(
+ command="curl -s 'https://wttr.in/Brooklyn?format=3'"
+ )
+ assert "blocked by safety guard" not in result
+
+
+@pytest.mark.asyncio
+async def test_curl_with_format_in_post_body_not_blocked():
+ """curl with 'format=json' in POST body should NOT be blocked."""
+ tool = ExecTool(working_dir="/tmp")
+ result = await tool.execute(
+ command="curl -s -d 'format=json' https://httpbin.org/post"
+ )
+ assert "blocked by safety guard" not in result
+
+
+@pytest.mark.asyncio
+async def test_curl_without_format_not_blocked():
+ """Plain curl commands should pass the guard."""
+ tool = ExecTool(working_dir="/tmp")
+ result = await tool.execute(command="curl -s https://httpbin.org/get")
+ assert "blocked by safety guard" not in result
+
+
+# --- The guard still blocks actual format commands ---
+
+
+@pytest.mark.asyncio
+async def test_guard_blocks_standalone_format_command():
+ """'format c:' as a standalone command must be blocked."""
+ tool = ExecTool(working_dir="/tmp")
+ result = await tool.execute(command="format c:")
+ assert "blocked by safety guard" in result
+
+
+@pytest.mark.asyncio
+async def test_guard_blocks_format_after_semicolon():
+ """'echo hi; format c:' must be blocked."""
+ tool = ExecTool(working_dir="/tmp")
+ result = await tool.execute(command="echo hi; format c:")
+ assert "blocked by safety guard" in result
+
+
+@pytest.mark.asyncio
+async def test_guard_blocks_format_after_pipe():
+ """'echo hi | format' must be blocked."""
+ tool = ExecTool(working_dir="/tmp")
+ result = await tool.execute(command="echo hi | format")
+ assert "blocked by safety guard" in result
+
+
+# --- Regex unit tests (no I/O) ---
+
+
+def test_format_pattern_blocks_disk_commands():
+ """The tightened pattern still catches actual format commands."""
+ pattern = r"(?:^|[;&|]\s*)format\b"
+
+ assert re.search(pattern, "format c:")
+ assert re.search(pattern, "echo hi; format c:")
+ assert re.search(pattern, "echo hi | format")
+ assert re.search(pattern, "cmd && format d:")
+
+
+def test_format_pattern_allows_urls_and_flags():
+ """The tightened pattern does NOT match format inside URLs or flags."""
+ pattern = r"(?:^|[;&|]\s*)format\b"
+
+ assert not re.search(pattern, "curl https://wttr.in?format=3")
+ assert not re.search(pattern, "echo --output-format=json")
+ assert not re.search(pattern, "curl -d 'format=json' https://api.example.com")
+ assert not re.search(pattern, "python -c 'print(\"{:format}\".format(1))'")
From 4367038a95a8ae96e0fdfbb37b5488cd9a9fbe49 Mon Sep 17 00:00:00 2001
From: Clayton Wilson
Date: Wed, 18 Feb 2026 13:32:06 -0600
Subject: [PATCH 173/506] fix: make cron run command actually execute the agent
Wire up an AgentLoop with an on_job callback in the cron_run CLI
command so the job's message is sent to the agent and the response
is printed. Previously, CronService was created with no on_job
callback, causing _execute_job to skip execution silently and
always report success.
Co-Authored-By: Claude Sonnet 4.6
---
nanobot/cli/commands.py | 44 ++++++++++++++++++++++++++++++++++++-----
1 file changed, 39 insertions(+), 5 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2f4ba7b..a45b4a7 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -860,17 +860,51 @@ def cron_run(
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
):
"""Manually run a job."""
- from nanobot.config.loader import get_data_dir
+ from nanobot.config.loader import load_config, get_data_dir
from nanobot.cron.service import CronService
-
+ from nanobot.bus.queue import MessageBus
+ from nanobot.agent.loop import AgentLoop
+ from loguru import logger
+ logger.disable("nanobot")
+
+ config = load_config()
+ provider = _make_provider(config)
+ bus = MessageBus()
+ agent_loop = AgentLoop(
+ bus=bus,
+ provider=provider,
+ workspace=config.workspace_path,
+ model=config.agents.defaults.model,
+ max_iterations=config.agents.defaults.max_tool_iterations,
+ memory_window=config.agents.defaults.memory_window,
+ exec_config=config.tools.exec,
+ restrict_to_workspace=config.tools.restrict_to_workspace,
+ )
+
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
-
+
+ result_holder = []
+
+ async def on_job(job):
+ response = await agent_loop.process_direct(
+ job.payload.message,
+ session_key=f"cron:{job.id}",
+ channel=job.payload.channel or "cli",
+ chat_id=job.payload.to or "direct",
+ )
+ result_holder.append(response)
+ return response
+
+ service.on_job = on_job
+
async def run():
return await service.run_job(job_id, force=force)
-
+
if asyncio.run(run()):
- console.print(f"[green]✓[/green] Job executed")
+ 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]")
From 107a380e61a57d72a23ea84c6ba8b68e0e2936cb Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Wed, 18 Feb 2026 21:22:22 -0300
Subject: [PATCH 174/506] fix: prevent duplicate memory consolidation tasks per
session
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a `_consolidating` set to track which sessions have an active
consolidation task. Skip creating a new task if one is already in
progress for the same session key, and clean up the flag when done.
This prevents the excessive API calls reported when messages exceed
the memory_window threshold — previously every single message after
the threshold triggered a new background consolidation.
Closes #751
---
nanobot/agent/loop.py | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index e5a5183..0e5d3b3 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -89,6 +89,7 @@ class AgentLoop:
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
+ self._consolidating: set[str] = set() # Session keys with consolidation in progress
self._register_default_tools()
def _register_default_tools(self) -> None:
@@ -318,8 +319,16 @@ class AgentLoop:
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
- if len(session.messages) > self.memory_window:
- asyncio.create_task(self._consolidate_memory(session))
+ if len(session.messages) > self.memory_window and session.key not in self._consolidating:
+ self._consolidating.add(session.key)
+
+ async def _consolidate_and_unlock():
+ try:
+ await self._consolidate_memory(session)
+ finally:
+ self._consolidating.discard(session.key)
+
+ asyncio.create_task(_consolidate_and_unlock())
self._set_tool_context(msg.channel, msg.chat_id)
initial_messages = self.context.build_messages(
From 33d760d31213edf8af992026d3a7e3da33bca52f Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Wed, 18 Feb 2026 21:27:13 -0300
Subject: [PATCH 175/506] fix: handle /help command directly in Telegram,
bypassing ACL check
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The /help command was routed through _forward_command → _handle_message
→ is_allowed(), which denied access to users not in the allowFrom list.
Since /help is purely informational, it should be accessible to all
users — similar to how /start already works with its own handler.
Add a dedicated _on_help handler that replies directly without going
through the message bus access control.
Closes #687
---
nanobot/channels/telegram.py | 20 +++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 39924b3..fa48fef 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -146,7 +146,7 @@ class TelegramChannel(BaseChannel):
# 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._forward_command))
+ self._app.add_handler(CommandHandler("help", self._on_help))
# Add message handler for text, photos, voice, documents
self._app.add_handler(
@@ -258,14 +258,28 @@ class TelegramChannel(BaseChannel):
"""Handle /start command."""
if not update.message or not update.effective_user:
return
-
+
user = update.effective_user
await update.message.reply_text(
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
"Send me a message and I'll respond!\n"
"Type /help to see available commands."
)
-
+
+ async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle /help command directly, bypassing access control.
+
+ /help is informational and should be accessible to all users,
+ even those not in the allowFrom list.
+ """
+ if not update.message:
+ return
+ await update.message.reply_text(
+ "🐈 nanobot commands:\n"
+ "/new — Start a new conversation\n"
+ "/help — Show available commands"
+ )
+
@staticmethod
def _sender_id(user) -> str:
"""Build sender_id with username for allowlist matching."""
From 464352c664c0c059457b8cceafbaa3844011a1d9 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Wed, 18 Feb 2026 21:29:10 -0300
Subject: [PATCH 176/506] fix: allow one retry for models that send interim
text before tool calls
Some LLM providers (MiniMax, Gemini Flash, GPT-4.1, etc.) send an
initial text-only response like "Let me investigate..." before actually
making tool calls. The agent loop previously broke immediately on any
text response without tool calls, preventing these models from ever
using tools.
Now, when the model responds with text but hasn't used any tools yet,
the loop forwards the text as progress to the user and gives the model
one additional iteration to make tool calls. This is limited to a
single retry to prevent infinite loops.
Closes #705
---
nanobot/agent/loop.py | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index e5a5183..6acbb38 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -183,6 +183,7 @@ class AgentLoop:
iteration = 0
final_content = None
tools_used: list[str] = []
+ text_only_retried = False
while iteration < self.max_iterations:
iteration += 1
@@ -226,6 +227,21 @@ class AgentLoop:
)
else:
final_content = self._strip_think(response.content)
+ # Some models (MiniMax, Gemini Flash, GPT-4.1, etc.) send an
+ # interim text response (e.g. "Let me investigate...") before
+ # making tool calls. If no tools have been used yet and we
+ # haven't already retried, forward the text as progress and
+ # give the model one more chance to use tools.
+ if not tools_used and not text_only_retried and final_content:
+ text_only_retried = True
+ logger.debug(f"Interim text response (no tools used yet), retrying: {final_content[:80]}")
+ if on_progress:
+ await on_progress(final_content)
+ messages = self.context.add_assistant_message(
+ messages, response.content,
+ reasoning_content=response.reasoning_content,
+ )
+ continue
break
return final_content, tools_used
From c7b5dd93502a829da18e2a7ef2253ae3298f2f28 Mon Sep 17 00:00:00 2001
From: chtangwin
Date: Tue, 10 Feb 2026 17:31:45 +0800
Subject: [PATCH 177/506] Fix: Ensure UTF-8 encoding for all file operations
---
nanobot/agent/loop.py | 3 ++-
nanobot/agent/subagent.py | 4 ++--
nanobot/config/loader.py | 2 +-
nanobot/cron/service.py | 4 ++--
nanobot/session/manager.py | 11 ++++++-----
5 files changed, 13 insertions(+), 11 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index e5a5183..1cd5730 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -206,7 +206,7 @@ class AgentLoop:
"type": "function",
"function": {
"name": tc.name,
- "arguments": json.dumps(tc.arguments)
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False)
}
}
for tc in response.tool_calls
@@ -388,6 +388,7 @@ class AgentLoop:
)
final_content, _ = await self._run_agent_loop(initial_messages)
+
if final_content is None:
final_content = "Background task completed."
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 203836a..ffefc08 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -146,7 +146,7 @@ class SubagentManager:
"type": "function",
"function": {
"name": tc.name,
- "arguments": json.dumps(tc.arguments),
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False),
},
}
for tc in response.tool_calls
@@ -159,7 +159,7 @@ class SubagentManager:
# Execute tools
for tool_call in response.tool_calls:
- args_str = json.dumps(tool_call.arguments)
+ args_str = json.dumps(tool_call.arguments, ensure_ascii=False)
logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}")
result = await tools.execute(tool_call.name, tool_call.arguments)
messages.append({
diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py
index 560c1f5..134e95f 100644
--- a/nanobot/config/loader.py
+++ b/nanobot/config/loader.py
@@ -31,7 +31,7 @@ def load_config(config_path: Path | None = None) -> Config:
if path.exists():
try:
- with open(path) as f:
+ with open(path, encoding="utf-8") as f:
data = json.load(f)
data = _migrate_config(data)
return Config.model_validate(data)
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index 14666e8..3c77452 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -66,7 +66,7 @@ class CronService:
if self.store_path.exists():
try:
- data = json.loads(self.store_path.read_text())
+ data = json.loads(self.store_path.read_text(encoding="utf-8"))
jobs = []
for j in data.get("jobs", []):
jobs.append(CronJob(
@@ -148,7 +148,7 @@ class CronService:
]
}
- self.store_path.write_text(json.dumps(data, indent=2))
+ self.store_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
async def start(self) -> None:
"""Start the cron service."""
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 752fce4..42df1b1 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -121,7 +121,7 @@ class SessionManager:
created_at = None
last_consolidated = 0
- with open(path) as f:
+ with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
@@ -151,7 +151,7 @@ class SessionManager:
"""Save a session to disk."""
path = self._get_session_path(session.key)
- with open(path, "w") as f:
+ with open(path, "w", encoding="utf-8") as f:
metadata_line = {
"_type": "metadata",
"created_at": session.created_at.isoformat(),
@@ -159,9 +159,10 @@ class SessionManager:
"metadata": session.metadata,
"last_consolidated": session.last_consolidated
}
- f.write(json.dumps(metadata_line) + "\n")
+ f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
for msg in session.messages:
- f.write(json.dumps(msg) + "\n")
+ f.write(json.dumps(msg, ensure_ascii=False) + "\n")
+
self._cache[session.key] = session
@@ -181,7 +182,7 @@ class SessionManager:
for path in self.sessions_dir.glob("*.jsonl"):
try:
# Read just the metadata line
- with open(path) as f:
+ with open(path, encoding="utf-8") as f:
first_line = f.readline().strip()
if first_line:
data = json.loads(first_line)
From a2379a08ac5467e4b3e628a7264427be73759a9f Mon Sep 17 00:00:00 2001
From: chtangwin
Date: Wed, 18 Feb 2026 18:37:17 -0800
Subject: [PATCH 178/506] Fix: Ensure UTF-8 encoding and ensure_ascii=False for
remaining file/JSON operations
---
nanobot/agent/tools/web.py | 8 ++++----
nanobot/channels/dingtalk.py | 2 +-
nanobot/cli/commands.py | 6 +++---
nanobot/config/loader.py | 4 ++--
nanobot/heartbeat/service.py | 2 +-
5 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py
index 9de1d3c..90cdda8 100644
--- a/nanobot/agent/tools/web.py
+++ b/nanobot/agent/tools/web.py
@@ -116,7 +116,7 @@ class WebFetchTool(Tool):
# 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})
+ return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
try:
async with httpx.AsyncClient(
@@ -131,7 +131,7 @@ class WebFetchTool(Tool):
# JSON
if "application/json" in ctype:
- text, extractor = json.dumps(r.json(), indent=2), "json"
+ text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
# HTML
elif "text/html" in ctype or r.text[:256].lower().startswith((" str:
"""Convert HTML to markdown."""
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 4a8cdd9..6b27af4 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -208,7 +208,7 @@ class DingTalkChannel(BaseChannel):
"msgParam": json.dumps({
"text": msg.content,
"title": "Nanobot Reply",
- }),
+ }, ensure_ascii=False),
}
if not self._http:
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2f4ba7b..d879d58 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -243,7 +243,7 @@ Information about the user goes here.
for filename, content in templates.items():
file_path = workspace / filename
if not file_path.exists():
- file_path.write_text(content)
+ file_path.write_text(content, encoding="utf-8")
console.print(f" [dim]Created {filename}[/dim]")
# Create memory directory and MEMORY.md
@@ -266,12 +266,12 @@ This file stores important information that should persist across sessions.
## Important Notes
(Things to remember)
-""")
+""", encoding="utf-8")
console.print(" [dim]Created memory/MEMORY.md[/dim]")
history_file = memory_dir / "HISTORY.md"
if not history_file.exists():
- history_file.write_text("")
+ history_file.write_text("", encoding="utf-8")
console.print(" [dim]Created memory/HISTORY.md[/dim]")
# Create skills directory for custom user skills
diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py
index 134e95f..c789efd 100644
--- a/nanobot/config/loader.py
+++ b/nanobot/config/loader.py
@@ -55,8 +55,8 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
data = config.model_dump(by_alias=True)
- with open(path, "w") as f:
- json.dump(data, f, 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:
diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py
index 221ed27..a51e5a0 100644
--- a/nanobot/heartbeat/service.py
+++ b/nanobot/heartbeat/service.py
@@ -65,7 +65,7 @@ class HeartbeatService:
"""Read HEARTBEAT.md content."""
if self.heartbeat_file.exists():
try:
- return self.heartbeat_file.read_text()
+ return self.heartbeat_file.read_text(encoding="utf-8")
except Exception:
return None
return None
From 124c611426eefe06aeb9a5b3d33338286228bb8a Mon Sep 17 00:00:00 2001
From: chtangwin
Date: Wed, 18 Feb 2026 18:46:23 -0800
Subject: [PATCH 179/506] Fix: Add ensure_ascii=False to WhatsApp send payload
The send() payload contains user message content (msg.content) which
may include non-ASCII characters (e.g. CJK, German umlauts, emoji).
The auth frame and Discord heartbeat/identify payloads are left
unchanged as they only carry ASCII protocol fields.
---
nanobot/channels/whatsapp.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 0cf2dd7..f799347 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -87,7 +87,7 @@ class WhatsAppChannel(BaseChannel):
"to": msg.chat_id,
"text": msg.content
}
- await self._ws.send(json.dumps(payload))
+ await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error(f"Error sending WhatsApp message: {e}")
From 523b2982f4fb2c0dcf74e3a4bb01ebf2a77fd529 Mon Sep 17 00:00:00 2001
From: Darye <54469750+DaryeDev@users.noreply.github.com>
Date: Thu, 19 Feb 2026 05:22:00 +0100
Subject: [PATCH 180/506] fix: fixed not logging tool uses if a think fragment
had them attached.
if a think fragment had a tool attached, the tool use would not log. now it does
---
nanobot/agent/loop.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index e5a5183..6b45b28 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -198,7 +198,9 @@ class AgentLoop:
if response.has_tool_calls:
if on_progress:
clean = self._strip_think(response.content)
- await on_progress(clean or self._tool_hint(response.tool_calls))
+ if clean:
+ await on_progress(clean)
+ await on_progress(self._tool_hint(response.tool_calls))
tool_call_dicts = [
{
From 9789307dd691d469881b45ad7e0a7f655bab110d Mon Sep 17 00:00:00 2001
From: PiEgg
Date: Thu, 19 Feb 2026 13:30:02 +0800
Subject: [PATCH 181/506] Fix Codex provider routing for GitHub Copilot models
---
nanobot/config/schema.py | 25 +++++++++++++-
nanobot/providers/litellm_provider.py | 13 +++++++-
nanobot/providers/openai_codex_provider.py | 2 +-
nanobot/providers/registry.py | 16 ++++++++-
tests/test_commands.py | 38 ++++++++++++++++++++++
5 files changed, 90 insertions(+), 4 deletions(-)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index ce9634c..9558072 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -287,11 +287,34 @@ class Config(BaseSettings):
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("-", "_")
+
+ def _matches_model_prefix(spec_name: str) -> bool:
+ if not model_prefix:
+ return False
+ return normalized_prefix == spec_name
+
+ def _keyword_matches(keyword: str) -> bool:
+ keyword_lower = keyword.lower()
+ return (
+ keyword_lower in model_lower
+ or keyword_lower.replace("-", "_") in model_normalized
+ )
+
+ # Explicit provider prefix in model name wins over generic keyword matches.
+ # This prevents `github-copilot/...codex` from being treated as OpenAI Codex.
+ for spec in PROVIDERS:
+ p = getattr(self.providers, spec.name, None)
+ if p and _matches_model_prefix(spec.name):
+ if spec.is_oauth or p.api_key:
+ return p, spec.name
# Match by keyword (order follows PROVIDERS registry)
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
- if p and any(kw in model_lower for kw in spec.keywords):
+ if p and any(_keyword_matches(kw) for kw in spec.keywords):
if spec.is_oauth or p.api_key:
return p, spec.name
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 8cc4e35..3fec618 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -88,10 +88,21 @@ class LiteLLMProvider(LLMProvider):
# Standard mode: auto-prefix for known providers
spec = find_by_model(model)
if spec and spec.litellm_prefix:
+ model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix)
if not any(model.startswith(s) for s in spec.skip_prefixes):
model = f"{spec.litellm_prefix}/{model}"
-
+
return model
+
+ @staticmethod
+ def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str:
+ """Normalize explicit provider prefixes like `github-copilot/...`."""
+ if "/" not in model:
+ return model
+ prefix, remainder = model.split("/", 1)
+ if prefix.lower().replace("-", "_") != spec_name:
+ return model
+ return f"{canonical_prefix}/{remainder}"
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
"""Apply model-specific parameter overrides from the registry."""
diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py
index 5067438..2336e71 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -80,7 +80,7 @@ class OpenAICodexProvider(LLMProvider):
def _strip_model_prefix(model: str) -> str:
- if model.startswith("openai-codex/"):
+ if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
return model.split("/", 1)[1]
return model
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 49b735c..d986fe6 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -384,10 +384,24 @@ def find_by_model(model: str) -> ProviderSpec | None:
"""Match a standard provider by model-name keyword (case-insensitive).
Skips gateways/local — those are matched by api_key/api_base instead."""
model_lower = model.lower()
+ model_normalized = model_lower.replace("-", "_")
+ model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
+ normalized_prefix = model_prefix.replace("-", "_")
+
+ # Prefer explicit provider prefix in model name.
for spec in PROVIDERS:
if spec.is_gateway or spec.is_local:
continue
- if any(kw in model_lower for kw in spec.keywords):
+ if model_prefix and normalized_prefix == spec.name:
+ return spec
+
+ for spec in PROVIDERS:
+ if spec.is_gateway or spec.is_local:
+ continue
+ if any(
+ kw in model_lower or kw.replace("-", "_") in model_normalized
+ for kw in spec.keywords
+ ):
return spec
return None
diff --git a/tests/test_commands.py b/tests/test_commands.py
index f5495fd..044d113 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -6,6 +6,10 @@ import pytest
from typer.testing import CliRunner
from nanobot.cli.commands import app
+from nanobot.config.schema import Config
+from nanobot.providers.litellm_provider import LiteLLMProvider
+from nanobot.providers.openai_codex_provider import _strip_model_prefix
+from nanobot.providers.registry import find_by_model
runner = CliRunner()
@@ -90,3 +94,37 @@ def test_onboard_existing_workspace_safe_create(mock_paths):
assert "Created workspace" not in result.stdout
assert "Created AGENTS.md" in result.stdout
assert (workspace_dir / "AGENTS.md").exists()
+
+
+def test_config_matches_github_copilot_codex_with_hyphen_prefix():
+ config = Config()
+ config.agents.defaults.model = "github-copilot/gpt-5.3-codex"
+
+ assert config.get_provider_name() == "github_copilot"
+
+
+def test_config_matches_openai_codex_with_hyphen_prefix():
+ config = Config()
+ config.agents.defaults.model = "openai-codex/gpt-5.1-codex"
+
+ assert config.get_provider_name() == "openai_codex"
+
+
+def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword():
+ spec = find_by_model("github-copilot/gpt-5.3-codex")
+
+ assert spec is not None
+ assert spec.name == "github_copilot"
+
+
+def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
+ provider = LiteLLMProvider(default_model="github-copilot/gpt-5.3-codex")
+
+ resolved = provider._resolve_model("github-copilot/gpt-5.3-codex")
+
+ assert resolved == "github_copilot/gpt-5.3-codex"
+
+
+def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
+ assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
+ assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
From d08c02225551b5410e126c91b7224c773b5c9187 Mon Sep 17 00:00:00 2001
From: Ubuntu
Date: Thu, 19 Feb 2026 16:31:00 +0800
Subject: [PATCH 182/506] feat(feishu): support sending images, audio, and
files
- Add image upload via im.v1.image.create API
- Add file upload via im.v1.file.create API
- Support sending images (.png, .jpg, .gif, etc.) as image messages
- Support sending audio (.opus) as voice messages
- Support sending other files as file messages
- Refactor send() to handle media attachments before text content
---
nanobot/channels/feishu.py | 179 ++++++++++++++++++++++++++++++-------
1 file changed, 149 insertions(+), 30 deletions(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index bc4a2b8..6f2881b 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -17,6 +17,10 @@ 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,
@@ -284,48 +288,163 @@ class FeishuChannel(BaseChannel):
return elements or [{"tag": "markdown", "content": content}]
+ # Image file extensions
+ _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
+
+ # Audio file extensions (Feishu only supports opus for audio messages)
+ _AUDIO_EXTS = {".opus"}
+
+ # File type mapping for Feishu file upload API
+ _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."""
+ import os
+ 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(f"Uploaded image {os.path.basename(file_path)}: {image_key}")
+ return image_key
+ else:
+ logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}")
+ return None
+ except Exception as e:
+ logger.error(f"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."""
+ import os
+ 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(f"Uploaded file {file_name}: {file_key}")
+ return file_key
+ else:
+ logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}")
+ return None
+ except Exception as e:
+ logger.error(f"Error uploading file {file_path}: {e}")
+ return None
+
+ 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(
+ f"Failed to send Feishu {msg_type} message: code={response.code}, "
+ f"msg={response.msg}, log_id={response.get_log_id()}"
+ )
+ return False
+ logger.debug(f"Feishu {msg_type} message sent to {receive_id}")
+ return True
+ except Exception as e:
+ logger.error(f"Error sending Feishu {msg_type} message: {e}")
+ return False
+
async def send(self, msg: OutboundMessage) -> None:
- """Send a message through Feishu."""
+ """Send a message through Feishu, including media (images/files) if present."""
if not self._client:
logger.warning("Feishu client not initialized")
return
-
+
try:
+ import os
+
# Determine receive_id_type based on chat_id format
# open_id starts with "ou_", chat_id starts with "oc_"
if msg.chat_id.startswith("oc_"):
receive_id_type = "chat_id"
else:
receive_id_type = "open_id"
-
- # Build card with markdown + table support
- elements = self._build_card_elements(msg.content)
- card = {
- "config": {"wide_screen_mode": True},
- "elements": elements,
- }
- content = json.dumps(card, ensure_ascii=False)
-
- request = CreateMessageRequest.builder() \
- .receive_id_type(receive_id_type) \
- .request_body(
- CreateMessageRequestBody.builder()
- .receive_id(msg.chat_id)
- .msg_type("interactive")
- .content(content)
- .build()
- ).build()
-
- response = self._client.im.v1.message.create(request)
-
- if not response.success():
- logger.error(
- f"Failed to send Feishu message: code={response.code}, "
- f"msg={response.msg}, log_id={response.get_log_id()}"
+
+ loop = asyncio.get_running_loop()
+
+ # --- Send media attachments first ---
+ if msg.media:
+ for file_path in msg.media:
+ if not os.path.isfile(file_path):
+ logger.warning(f"Media file not found: {file_path}")
+ continue
+
+ ext = os.path.splitext(file_path)[1].lower()
+ if ext in self._IMAGE_EXTS:
+ # Upload and send as image
+ image_key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
+ if image_key:
+ content = json.dumps({"image_key": image_key})
+ await loop.run_in_executor(
+ None, self._send_message_sync,
+ receive_id_type, msg.chat_id, "image", content,
+ )
+ elif ext in self._AUDIO_EXTS:
+ # Upload and send as audio (voice message)
+ file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
+ if file_key:
+ content = json.dumps({"file_key": file_key})
+ await loop.run_in_executor(
+ None, self._send_message_sync,
+ receive_id_type, msg.chat_id, "audio", content,
+ )
+ else:
+ # Upload and send as file
+ file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
+ if file_key:
+ content = json.dumps({"file_key": file_key})
+ await loop.run_in_executor(
+ None, self._send_message_sync,
+ receive_id_type, msg.chat_id, "file", content,
+ )
+
+ # --- Send text content (if any) ---
+ if msg.content and msg.content.strip():
+ # Build card with markdown + table support
+ elements = self._build_card_elements(msg.content)
+ card = {
+ "config": {"wide_screen_mode": True},
+ "elements": elements,
+ }
+ content = json.dumps(card, ensure_ascii=False)
+ await loop.run_in_executor(
+ None, self._send_message_sync,
+ receive_id_type, msg.chat_id, "interactive", content,
)
- else:
- logger.debug(f"Feishu message sent to {msg.chat_id}")
-
+
except Exception as e:
logger.error(f"Error sending Feishu message: {e}")
From 1b49bf96021eba1bfc95e3c0a1ab6cae36271973 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Thu, 19 Feb 2026 10:26:49 -0300
Subject: [PATCH 183/506] fix: avoid duplicate messages on retry and reset
final_content
Address review feedback:
- Remove on_progress call for interim text to prevent duplicate
messages when the model simply answers a direct question
- Reset final_content to None before continue to avoid stale
interim text leaking as the final response on empty retry
Closes #705
---
nanobot/agent/loop.py | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 6acbb38..532488f 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -230,17 +230,18 @@ class AgentLoop:
# Some models (MiniMax, Gemini Flash, GPT-4.1, etc.) send an
# interim text response (e.g. "Let me investigate...") before
# making tool calls. If no tools have been used yet and we
- # haven't already retried, forward the text as progress and
- # give the model one more chance to use tools.
+ # haven't already retried, add the text to the conversation
+ # and give the model one more chance to use tools.
+ # We do NOT forward the interim text as progress to avoid
+ # duplicate messages when the model simply answers directly.
if not tools_used and not text_only_retried and final_content:
text_only_retried = True
logger.debug(f"Interim text response (no tools used yet), retrying: {final_content[:80]}")
- if on_progress:
- await on_progress(final_content)
messages = self.context.add_assistant_message(
messages, response.content,
reasoning_content=response.reasoning_content,
)
+ final_content = None
continue
break
From c86dbc9f45e4467d54ec0a56ce5597219071d8ee Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Thu, 19 Feb 2026 10:27:11 -0300
Subject: [PATCH 184/506] fix: wait for killed process after shell timeout to
prevent fd leaks
When a shell command times out, process.kill() is called but the
process object was never awaited after that. This leaves subprocess
pipes undrained and file descriptors open. If many commands time out,
fd leaks accumulate.
Add a bounded wait (5s) after kill to let the process fully terminate
and release its resources.
---
nanobot/agent/tools/shell.py | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index 18eff64..ceac6b7 100644
--- a/nanobot/agent/tools/shell.py
+++ b/nanobot/agent/tools/shell.py
@@ -81,6 +81,12 @@ class ExecTool(Tool):
)
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 = []
From 3b4763b3f989c00da674933f459730717ea7385a Mon Sep 17 00:00:00 2001
From: tercerapersona
Date: Thu, 19 Feb 2026 11:05:22 -0300
Subject: [PATCH 185/506] feat: add Anthropic prompt caching via cache_control
Inject cache_control: {"type": "ephemeral"} on the system message and
last tool definition for providers that support prompt caching. Adds
supports_prompt_caching flag to ProviderSpec (enabled for Anthropic only)
and skips caching when routing through a gateway.
Co-Authored-By: Claude Sonnet 4.6
---
nanobot/providers/litellm_provider.py | 43 +++++++++++++++++++++++++--
nanobot/providers/registry.py | 4 +++
2 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 8cc4e35..950a138 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -93,6 +93,41 @@ class LiteLLMProvider(LLMProvider):
return model
+ def _supports_cache_control(self, model: str) -> bool:
+ """Return True when the provider supports cache_control on content blocks."""
+ if self._gateway is not None:
+ return False
+ spec = find_by_model(model)
+ return spec is not None and spec.supports_prompt_caching
+
+ def _apply_cache_control(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None,
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]:
+ """Return copies of messages and tools with cache_control injected."""
+ # Transform the system message
+ new_messages = []
+ for msg in messages:
+ if msg.get("role") == "system":
+ content = msg["content"]
+ if isinstance(content, str):
+ new_content = [{"type": "text", "text": content, "cache_control": {"type": "ephemeral"}}]
+ else:
+ new_content = list(content)
+ new_content[-1] = {**new_content[-1], "cache_control": {"type": "ephemeral"}}
+ new_messages.append({**msg, "content": new_content})
+ else:
+ new_messages.append(msg)
+
+ # Add cache_control to the last tool definition
+ new_tools = tools
+ if tools:
+ new_tools = list(tools)
+ new_tools[-1] = {**new_tools[-1], "cache_control": {"type": "ephemeral"}}
+
+ return new_messages, new_tools
+
def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None:
"""Apply model-specific parameter overrides from the registry."""
model_lower = model.lower()
@@ -124,8 +159,12 @@ class LiteLLMProvider(LLMProvider):
Returns:
LLMResponse with content and/or tool calls.
"""
- model = self._resolve_model(model or self.default_model)
-
+ original_model = model or self.default_model
+ model = self._resolve_model(original_model)
+
+ if self._supports_cache_control(original_model):
+ messages, tools = self._apply_cache_control(messages, tools)
+
# Clamp max_tokens to at least 1 — negative or zero values cause
# LiteLLM to reject the request with "max_tokens must be at least 1".
max_tokens = max(1, max_tokens)
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 49b735c..2584e0e 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -57,6 +57,9 @@ class ProviderSpec:
# Direct providers bypass LiteLLM entirely (e.g., CustomProvider)
is_direct: bool = False
+ # Provider supports cache_control on content blocks (e.g. Anthropic prompt caching)
+ supports_prompt_caching: bool = False
+
@property
def label(self) -> str:
return self.display_name or self.name.title()
@@ -155,6 +158,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="",
strip_model_prefix=False,
model_overrides=(),
+ supports_prompt_caching=True,
),
# OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed.
From d748e6eca33baf4b419c624b2a5e702b0d5b6b35 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 19 Feb 2026 17:28:13 +0000
Subject: [PATCH 186/506] fix: pin dependency version ranges
---
pyproject.toml | 54 +++++++++++++++++++++++++-------------------------
1 file changed, 27 insertions(+), 27 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index bbd6feb..64a884d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,37 +17,37 @@ classifiers = [
]
dependencies = [
- "typer>=0.9.0",
- "litellm>=1.0.0",
- "pydantic>=2.0.0",
- "pydantic-settings>=2.0.0",
- "websockets>=12.0",
- "websocket-client>=1.6.0",
- "httpx>=0.25.0",
- "oauth-cli-kit>=0.1.1",
- "loguru>=0.7.0",
- "readability-lxml>=0.8.0",
- "rich>=13.0.0",
- "croniter>=2.0.0",
- "dingtalk-stream>=0.4.0",
- "python-telegram-bot[socks]>=21.0",
- "lark-oapi>=1.0.0",
- "socksio>=1.0.0",
- "python-socketio>=5.11.0",
- "msgpack>=1.0.8",
- "slack-sdk>=3.26.0",
- "slackify-markdown>=0.2.0",
- "qq-botpy>=1.0.0",
- "python-socks[asyncio]>=2.4.0",
- "prompt-toolkit>=3.0.0",
- "mcp>=1.0.0",
- "json-repair>=0.30.0",
+ "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",
]
[project.optional-dependencies]
dev = [
- "pytest>=7.0.0",
- "pytest-asyncio>=0.21.0",
+ "pytest>=9.0.0,<10.0.0",
+ "pytest-asyncio>=1.3.0,<2.0.0",
"ruff>=0.1.0",
]
From 3890f1a7dd040050df64b11755d392c8b2689806 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 19 Feb 2026 17:33:08 +0000
Subject: [PATCH 187/506] refactor(feishu): clean up send() and remove dead
code
---
nanobot/channels/feishu.py | 85 +++++++++++---------------------------
1 file changed, 24 insertions(+), 61 deletions(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 6f2881b..651d655 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -2,6 +2,7 @@
import asyncio
import json
+import os
import re
import threading
from collections import OrderedDict
@@ -267,7 +268,6 @@ class FeishuChannel(BaseChannel):
before = protected[last_end:m.start()].strip()
if before:
elements.append({"tag": "markdown", "content": before})
- level = len(m.group(1))
text = m.group(2).strip()
elements.append({
"tag": "div",
@@ -288,13 +288,8 @@ class FeishuChannel(BaseChannel):
return elements or [{"tag": "markdown", "content": content}]
- # Image file extensions
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
-
- # Audio file extensions (Feishu only supports opus for audio messages)
_AUDIO_EXTS = {".opus"}
-
- # File type mapping for Feishu file upload API
_FILE_TYPE_MAP = {
".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt",
@@ -302,7 +297,6 @@ class FeishuChannel(BaseChannel):
def _upload_image_sync(self, file_path: str) -> str | None:
"""Upload an image to Feishu and return the image_key."""
- import os
try:
with open(file_path, "rb") as f:
request = CreateImageRequest.builder() \
@@ -326,7 +320,6 @@ class FeishuChannel(BaseChannel):
def _upload_file_sync(self, file_path: str) -> str | None:
"""Upload a file to Feishu and return the file_key."""
- import os
ext = os.path.splitext(file_path)[1].lower()
file_type = self._FILE_TYPE_MAP.get(ext, "stream")
file_name = os.path.basename(file_path)
@@ -384,65 +377,35 @@ class FeishuChannel(BaseChannel):
return
try:
- import os
-
- # Determine receive_id_type based on chat_id format
- # open_id starts with "ou_", chat_id starts with "oc_"
- if msg.chat_id.startswith("oc_"):
- receive_id_type = "chat_id"
- else:
- receive_id_type = "open_id"
-
+ receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
loop = asyncio.get_running_loop()
- # --- Send media attachments first ---
- if msg.media:
- for file_path in msg.media:
- if not os.path.isfile(file_path):
- logger.warning(f"Media file not found: {file_path}")
- continue
+ for file_path in msg.media:
+ if not os.path.isfile(file_path):
+ logger.warning(f"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}),
+ )
+ 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}),
+ )
- ext = os.path.splitext(file_path)[1].lower()
- if ext in self._IMAGE_EXTS:
- # Upload and send as image
- image_key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
- if image_key:
- content = json.dumps({"image_key": image_key})
- await loop.run_in_executor(
- None, self._send_message_sync,
- receive_id_type, msg.chat_id, "image", content,
- )
- elif ext in self._AUDIO_EXTS:
- # Upload and send as audio (voice message)
- file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
- if file_key:
- content = json.dumps({"file_key": file_key})
- await loop.run_in_executor(
- None, self._send_message_sync,
- receive_id_type, msg.chat_id, "audio", content,
- )
- else:
- # Upload and send as file
- file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
- if file_key:
- content = json.dumps({"file_key": file_key})
- await loop.run_in_executor(
- None, self._send_message_sync,
- receive_id_type, msg.chat_id, "file", content,
- )
-
- # --- Send text content (if any) ---
if msg.content and msg.content.strip():
- # Build card with markdown + table support
- elements = self._build_card_elements(msg.content)
- card = {
- "config": {"wide_screen_mode": True},
- "elements": elements,
- }
- content = json.dumps(card, ensure_ascii=False)
+ 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", content,
+ receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
)
except Exception as e:
From b11f0ce6a9bc8159ed1a7a6937c7c93c80a54733 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 19 Feb 2026 17:39:44 +0000
Subject: [PATCH 188/506] fix: prefer explicit provider prefix over keyword
match to fix Codex routing
---
nanobot/config/schema.py | 21 ++++++---------------
nanobot/providers/registry.py | 16 +++++-----------
2 files changed, 11 insertions(+), 26 deletions(-)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 9558072..6a1257e 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -291,30 +291,21 @@ class Config(BaseSettings):
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_")
- def _matches_model_prefix(spec_name: str) -> bool:
- if not model_prefix:
- return False
- return normalized_prefix == spec_name
+ def _kw_matches(kw: str) -> bool:
+ kw = kw.lower()
+ return kw in model_lower or kw.replace("-", "_") in model_normalized
- def _keyword_matches(keyword: str) -> bool:
- keyword_lower = keyword.lower()
- return (
- keyword_lower in model_lower
- or keyword_lower.replace("-", "_") in model_normalized
- )
-
- # Explicit provider prefix in model name wins over generic keyword matches.
- # This prevents `github-copilot/...codex` from being treated as OpenAI Codex.
+ # Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex.
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
- if p and _matches_model_prefix(spec.name):
+ if p and model_prefix and normalized_prefix == spec.name:
if spec.is_oauth or p.api_key:
return p, spec.name
# Match by keyword (order follows PROVIDERS registry)
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
- if p and any(_keyword_matches(kw) for kw in spec.keywords):
+ if p and any(_kw_matches(kw) for kw in spec.keywords):
if spec.is_oauth or p.api_key:
return p, spec.name
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index d986fe6..3071793 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -387,21 +387,15 @@ def find_by_model(model: str) -> ProviderSpec | None:
model_normalized = model_lower.replace("-", "_")
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_")
+ std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local]
- # Prefer explicit provider prefix in model name.
- for spec in PROVIDERS:
- if spec.is_gateway or spec.is_local:
- continue
+ # Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex.
+ for spec in std_specs:
if model_prefix and normalized_prefix == spec.name:
return spec
- for spec in PROVIDERS:
- if spec.is_gateway or spec.is_local:
- continue
- if any(
- kw in model_lower or kw.replace("-", "_") in model_normalized
- for kw in spec.keywords
- ):
+ for spec in std_specs:
+ if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords):
return spec
return None
From fbfb030a6eeabf6b3abf601f7cbe6514da876943 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 19 Feb 2026 17:48:09 +0000
Subject: [PATCH 189/506] chore: remove network-dependent test file for shell
guard
---
tests/test_exec_curl.py | 97 -----------------------------------------
1 file changed, 97 deletions(-)
delete mode 100644 tests/test_exec_curl.py
diff --git a/tests/test_exec_curl.py b/tests/test_exec_curl.py
deleted file mode 100644
index 2f53fcf..0000000
--- a/tests/test_exec_curl.py
+++ /dev/null
@@ -1,97 +0,0 @@
-r"""Tests for ExecTool safety guard — format pattern false positive.
-
-The old deny pattern `\b(format|mkfs|diskpart)\b` matched "format" inside
-URLs (e.g. `curl https://wttr.in?format=3`) because `?` is a non-word
-character, so `\b` fires between `?` and `f`.
-
-The fix splits the pattern:
- - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command
- - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive)
-"""
-
-import re
-
-import pytest
-
-from nanobot.agent.tools.shell import ExecTool
-
-
-# --- Guard regression: "format" in URLs must not be blocked ---
-
-
-@pytest.mark.asyncio
-async def test_curl_with_format_in_url_not_blocked():
- """curl with ?format= in URL should NOT be blocked by the guard."""
- tool = ExecTool(working_dir="/tmp")
- result = await tool.execute(
- command="curl -s 'https://wttr.in/Brooklyn?format=3'"
- )
- assert "blocked by safety guard" not in result
-
-
-@pytest.mark.asyncio
-async def test_curl_with_format_in_post_body_not_blocked():
- """curl with 'format=json' in POST body should NOT be blocked."""
- tool = ExecTool(working_dir="/tmp")
- result = await tool.execute(
- command="curl -s -d 'format=json' https://httpbin.org/post"
- )
- assert "blocked by safety guard" not in result
-
-
-@pytest.mark.asyncio
-async def test_curl_without_format_not_blocked():
- """Plain curl commands should pass the guard."""
- tool = ExecTool(working_dir="/tmp")
- result = await tool.execute(command="curl -s https://httpbin.org/get")
- assert "blocked by safety guard" not in result
-
-
-# --- The guard still blocks actual format commands ---
-
-
-@pytest.mark.asyncio
-async def test_guard_blocks_standalone_format_command():
- """'format c:' as a standalone command must be blocked."""
- tool = ExecTool(working_dir="/tmp")
- result = await tool.execute(command="format c:")
- assert "blocked by safety guard" in result
-
-
-@pytest.mark.asyncio
-async def test_guard_blocks_format_after_semicolon():
- """'echo hi; format c:' must be blocked."""
- tool = ExecTool(working_dir="/tmp")
- result = await tool.execute(command="echo hi; format c:")
- assert "blocked by safety guard" in result
-
-
-@pytest.mark.asyncio
-async def test_guard_blocks_format_after_pipe():
- """'echo hi | format' must be blocked."""
- tool = ExecTool(working_dir="/tmp")
- result = await tool.execute(command="echo hi | format")
- assert "blocked by safety guard" in result
-
-
-# --- Regex unit tests (no I/O) ---
-
-
-def test_format_pattern_blocks_disk_commands():
- """The tightened pattern still catches actual format commands."""
- pattern = r"(?:^|[;&|]\s*)format\b"
-
- assert re.search(pattern, "format c:")
- assert re.search(pattern, "echo hi; format c:")
- assert re.search(pattern, "echo hi | format")
- assert re.search(pattern, "cmd && format d:")
-
-
-def test_format_pattern_allows_urls_and_flags():
- """The tightened pattern does NOT match format inside URLs or flags."""
- pattern = r"(?:^|[;&|]\s*)format\b"
-
- assert not re.search(pattern, "curl https://wttr.in?format=3")
- assert not re.search(pattern, "echo --output-format=json")
- assert not re.search(pattern, "curl -d 'format=json' https://api.example.com")
- assert not re.search(pattern, "python -c 'print(\"{:format}\".format(1))'")
From 53b83a38e2dc44b91e7890594abb1bd2220a6b03 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Thu, 19 Feb 2026 17:19:36 -0300
Subject: [PATCH 190/506] fix: use loguru native formatting to prevent KeyError
on messages containing curly braces
Closes #857
---
nanobot/agent/loop.py | 12 ++++++------
nanobot/agent/subagent.py | 4 ++--
nanobot/agent/tools/mcp.py | 2 +-
nanobot/bus/queue.py | 2 +-
nanobot/channels/dingtalk.py | 18 +++++++++---------
nanobot/channels/discord.py | 10 +++++-----
nanobot/channels/email.py | 4 ++--
nanobot/channels/feishu.py | 26 +++++++++++++-------------
nanobot/channels/manager.py | 6 +++---
nanobot/channels/mochat.py | 24 ++++++++++++------------
nanobot/channels/qq.py | 6 +++---
nanobot/channels/slack.py | 8 ++++----
nanobot/channels/telegram.py | 16 ++++++++--------
nanobot/channels/whatsapp.py | 10 +++++-----
nanobot/cron/service.py | 4 ++--
nanobot/heartbeat/service.py | 4 ++--
nanobot/providers/transcription.py | 2 +-
nanobot/session/manager.py | 2 +-
18 files changed, 80 insertions(+), 80 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index e5a5183..cbab5aa 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -219,7 +219,7 @@ class AgentLoop:
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(f"Tool call: {tool_call.name}({args_str[:200]})")
+ logger.info("Tool call: {}({})", tool_call.name, args_str[:200])
result = await self.tools.execute(tool_call.name, tool_call.arguments)
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
@@ -247,7 +247,7 @@ class AgentLoop:
if response:
await self.bus.publish_outbound(response)
except Exception as e:
- logger.error(f"Error processing message: {e}")
+ logger.error("Error processing message: {}", e)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
@@ -292,7 +292,7 @@ class AgentLoop:
return await self._process_system_message(msg)
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
- logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
+ 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)
@@ -344,7 +344,7 @@ class AgentLoop:
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(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
+ logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
session.add_message("user", msg.content)
session.add_message("assistant", final_content,
@@ -469,7 +469,7 @@ Respond with ONLY valid JSON, no markdown fences."""
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
result = json_repair.loads(text)
if not isinstance(result, dict):
- logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}")
+ logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200])
return
if entry := result.get("history_entry"):
@@ -484,7 +484,7 @@ Respond with ONLY valid JSON, no markdown fences."""
session.last_consolidated = len(session.messages) - keep_count
logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}")
except Exception as e:
- logger.error(f"Memory consolidation failed: {e}")
+ logger.error("Memory consolidation failed: {}", e)
async def process_direct(
self,
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 203836a..ae0e492 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -160,7 +160,7 @@ class SubagentManager:
# Execute tools
for tool_call in response.tool_calls:
args_str = json.dumps(tool_call.arguments)
- logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}")
+ logger.debug("Subagent [{}] executing: {} with arguments: {}", task_id, tool_call.name, args_str)
result = await tools.execute(tool_call.name, tool_call.arguments)
messages.append({
"role": "tool",
@@ -180,7 +180,7 @@ class SubagentManager:
except Exception as e:
error_msg = f"Error: {str(e)}"
- logger.error(f"Subagent [{task_id}] failed: {e}")
+ logger.error("Subagent [{}] failed: {}", task_id, e)
await self._announce_result(task_id, label, task, error_msg, origin, "error")
async def _announce_result(
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index 1c8eac4..4e61923 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -77,4 +77,4 @@ async def connect_mcp_servers(
logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
except Exception as e:
- logger.error(f"MCP server '{name}': failed to connect: {e}")
+ logger.error("MCP server '{}': failed to connect: {}", name, e)
diff --git a/nanobot/bus/queue.py b/nanobot/bus/queue.py
index 4123d06..554c0ec 100644
--- a/nanobot/bus/queue.py
+++ b/nanobot/bus/queue.py
@@ -62,7 +62,7 @@ class MessageBus:
try:
await callback(msg)
except Exception as e:
- logger.error(f"Error dispatching to {msg.channel}: {e}")
+ logger.error("Error dispatching to {}: {}", msg.channel, e)
except asyncio.TimeoutError:
continue
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 4a8cdd9..3ac233f 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -65,7 +65,7 @@ class NanobotDingTalkHandler(CallbackHandler):
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
sender_name = chatbot_msg.sender_nick or "Unknown"
- logger.info(f"Received DingTalk message from {sender_name} ({sender_id}): {content}")
+ 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.
@@ -78,7 +78,7 @@ class NanobotDingTalkHandler(CallbackHandler):
return AckMessage.STATUS_OK, "OK"
except Exception as e:
- logger.error(f"Error processing DingTalk message: {e}")
+ logger.error("Error processing DingTalk message: {}", e)
# Return OK to avoid retry loop from DingTalk server
return AckMessage.STATUS_OK, "Error"
@@ -142,13 +142,13 @@ class DingTalkChannel(BaseChannel):
try:
await self._client.start()
except Exception as e:
- logger.warning(f"DingTalk stream error: {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(f"Failed to start DingTalk channel: {e}")
+ logger.exception("Failed to start DingTalk channel: {}", e)
async def stop(self) -> None:
"""Stop the DingTalk bot."""
@@ -186,7 +186,7 @@ class DingTalkChannel(BaseChannel):
self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60
return self._access_token
except Exception as e:
- logger.error(f"Failed to get DingTalk access token: {e}")
+ logger.error("Failed to get DingTalk access token: {}", e)
return None
async def send(self, msg: OutboundMessage) -> None:
@@ -218,11 +218,11 @@ class DingTalkChannel(BaseChannel):
try:
resp = await self._http.post(url, json=data, headers=headers)
if resp.status_code != 200:
- logger.error(f"DingTalk send failed: {resp.text}")
+ logger.error("DingTalk send failed: {}", resp.text)
else:
logger.debug(f"DingTalk message sent to {msg.chat_id}")
except Exception as e:
- logger.error(f"Error sending DingTalk message: {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).
@@ -231,7 +231,7 @@ class DingTalkChannel(BaseChannel):
permission checks before publishing to the bus.
"""
try:
- logger.info(f"DingTalk inbound: {content} from {sender_name}")
+ 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
@@ -242,4 +242,4 @@ class DingTalkChannel(BaseChannel):
},
)
except Exception as e:
- logger.error(f"Error publishing DingTalk message: {e}")
+ logger.error("Error publishing DingTalk message: {}", e)
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index a76d6ac..ee54eed 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -51,7 +51,7 @@ class DiscordChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
- logger.warning(f"Discord gateway error: {e}")
+ logger.warning("Discord gateway error: {}", e)
if self._running:
logger.info("Reconnecting to Discord gateway in 5 seconds...")
await asyncio.sleep(5)
@@ -101,7 +101,7 @@ class DiscordChannel(BaseChannel):
return
except Exception as e:
if attempt == 2:
- logger.error(f"Error sending Discord message: {e}")
+ logger.error("Error sending Discord message: {}", e)
else:
await asyncio.sleep(1)
finally:
@@ -116,7 +116,7 @@ class DiscordChannel(BaseChannel):
try:
data = json.loads(raw)
except json.JSONDecodeError:
- logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}")
+ logger.warning("Invalid JSON from Discord gateway: {}", raw[:100])
continue
op = data.get("op")
@@ -175,7 +175,7 @@ class DiscordChannel(BaseChannel):
try:
await self._ws.send(json.dumps(payload))
except Exception as e:
- logger.warning(f"Discord heartbeat failed: {e}")
+ logger.warning("Discord heartbeat failed: {}", e)
break
await asyncio.sleep(interval_s)
@@ -219,7 +219,7 @@ class DiscordChannel(BaseChannel):
media_paths.append(str(file_path))
content_parts.append(f"[attachment: {file_path}]")
except Exception as e:
- logger.warning(f"Failed to download Discord attachment: {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")
diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py
index 0e47067..8a1ee79 100644
--- a/nanobot/channels/email.py
+++ b/nanobot/channels/email.py
@@ -94,7 +94,7 @@ class EmailChannel(BaseChannel):
metadata=item.get("metadata", {}),
)
except Exception as e:
- logger.error(f"Email polling error: {e}")
+ logger.error("Email polling error: {}", e)
await asyncio.sleep(poll_seconds)
@@ -143,7 +143,7 @@ class EmailChannel(BaseChannel):
try:
await asyncio.to_thread(self._smtp_send, email_msg)
except Exception as e:
- logger.error(f"Error sending email to {to_addr}: {e}")
+ logger.error("Error sending email to {}: {}", to_addr, e)
raise
def _validate_config(self) -> bool:
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 651d655..6f62202 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -156,7 +156,7 @@ class FeishuChannel(BaseChannel):
try:
self._ws_client.start()
except Exception as e:
- logger.warning(f"Feishu WebSocket error: {e}")
+ logger.warning("Feishu WebSocket error: {}", e)
if self._running:
import time; time.sleep(5)
@@ -177,7 +177,7 @@ class FeishuChannel(BaseChannel):
try:
self._ws_client.stop()
except Exception as e:
- logger.warning(f"Error stopping WebSocket client: {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:
@@ -194,11 +194,11 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.message_reaction.create(request)
if not response.success():
- logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}")
+ logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg)
else:
logger.debug(f"Added {emoji_type} reaction to message {message_id}")
except Exception as e:
- logger.warning(f"Error adding reaction: {e}")
+ logger.warning("Error adding reaction: {}", e)
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None:
"""
@@ -312,10 +312,10 @@ class FeishuChannel(BaseChannel):
logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}")
return image_key
else:
- logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}")
+ logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg)
return None
except Exception as e:
- logger.error(f"Error uploading image {file_path}: {e}")
+ logger.error("Error uploading image {}: {}", file_path, e)
return None
def _upload_file_sync(self, file_path: str) -> str | None:
@@ -339,10 +339,10 @@ class FeishuChannel(BaseChannel):
logger.debug(f"Uploaded file {file_name}: {file_key}")
return file_key
else:
- logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}")
+ logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg)
return None
except Exception as e:
- logger.error(f"Error uploading file {file_path}: {e}")
+ logger.error("Error uploading file {}: {}", file_path, e)
return None
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool:
@@ -360,14 +360,14 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.message.create(request)
if not response.success():
logger.error(
- f"Failed to send Feishu {msg_type} message: code={response.code}, "
- f"msg={response.msg}, log_id={response.get_log_id()}"
+ "Failed to send Feishu {} message: code={}, msg={}, log_id={}",
+ msg_type, response.code, response.msg, response.get_log_id()
)
return False
logger.debug(f"Feishu {msg_type} message sent to {receive_id}")
return True
except Exception as e:
- logger.error(f"Error sending Feishu {msg_type} message: {e}")
+ logger.error("Error sending Feishu {} message: {}", msg_type, e)
return False
async def send(self, msg: OutboundMessage) -> None:
@@ -409,7 +409,7 @@ class FeishuChannel(BaseChannel):
)
except Exception as e:
- logger.error(f"Error sending Feishu message: {e}")
+ logger.error("Error sending Feishu message: {}", e)
def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None:
"""
@@ -481,4 +481,4 @@ class FeishuChannel(BaseChannel):
)
except Exception as e:
- logger.error(f"Error processing Feishu message: {e}")
+ logger.error("Error processing Feishu message: {}", e)
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index e860d26..3e714c3 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -142,7 +142,7 @@ class ChannelManager:
try:
await channel.start()
except Exception as e:
- logger.error(f"Failed to start channel {name}: {e}")
+ logger.error("Failed to start channel {}: {}", name, e)
async def start_all(self) -> None:
"""Start all channels and the outbound dispatcher."""
@@ -180,7 +180,7 @@ class ChannelManager:
await channel.stop()
logger.info(f"Stopped {name} channel")
except Exception as e:
- logger.error(f"Error stopping {name}: {e}")
+ logger.error("Error stopping {}: {}", name, e)
async def _dispatch_outbound(self) -> None:
"""Dispatch outbound messages to the appropriate channel."""
@@ -198,7 +198,7 @@ class ChannelManager:
try:
await channel.send(msg)
except Exception as e:
- logger.error(f"Error sending to {msg.channel}: {e}")
+ logger.error("Error sending to {}: {}", msg.channel, e)
else:
logger.warning(f"Unknown channel: {msg.channel}")
diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py
index 30c3dbf..e762dfd 100644
--- a/nanobot/channels/mochat.py
+++ b/nanobot/channels/mochat.py
@@ -322,7 +322,7 @@ class MochatChannel(BaseChannel):
await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
content, msg.reply_to)
except Exception as e:
- logger.error(f"Failed to send Mochat message: {e}")
+ logger.error("Failed to send Mochat message: {}", e)
# ---- config / init helpers ---------------------------------------------
@@ -380,7 +380,7 @@ class MochatChannel(BaseChannel):
@client.event
async def connect_error(data: Any) -> None:
- logger.error(f"Mochat websocket connect error: {data}")
+ logger.error("Mochat websocket connect error: {}", data)
@client.on("claw.session.events")
async def on_session_events(payload: dict[str, Any]) -> None:
@@ -407,7 +407,7 @@ class MochatChannel(BaseChannel):
)
return True
except Exception as e:
- logger.error(f"Failed to connect Mochat websocket: {e}")
+ logger.error("Failed to connect Mochat websocket: {}", e)
try:
await client.disconnect()
except Exception:
@@ -444,7 +444,7 @@ class MochatChannel(BaseChannel):
"limit": self.config.watch_limit,
})
if not ack.get("result"):
- logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}")
+ logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error'))
return False
data = ack.get("data")
@@ -466,7 +466,7 @@ class MochatChannel(BaseChannel):
return True
ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
if not ack.get("result"):
- logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}")
+ logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error'))
return False
return True
@@ -488,7 +488,7 @@ class MochatChannel(BaseChannel):
try:
await self._refresh_targets(subscribe_new=self._ws_ready)
except Exception as e:
- logger.warning(f"Mochat refresh failed: {e}")
+ logger.warning("Mochat refresh failed: {}", e)
if self._fallback_mode:
await self._ensure_fallback_workers()
@@ -502,7 +502,7 @@ class MochatChannel(BaseChannel):
try:
response = await self._post_json("/api/claw/sessions/list", {})
except Exception as e:
- logger.warning(f"Mochat listSessions failed: {e}")
+ logger.warning("Mochat listSessions failed: {}", e)
return
sessions = response.get("sessions")
@@ -536,7 +536,7 @@ class MochatChannel(BaseChannel):
try:
response = await self._post_json("/api/claw/groups/get", {})
except Exception as e:
- logger.warning(f"Mochat getWorkspaceGroup failed: {e}")
+ logger.warning("Mochat getWorkspaceGroup failed: {}", e)
return
raw_panels = response.get("panels")
@@ -598,7 +598,7 @@ class MochatChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
- logger.warning(f"Mochat watch fallback error ({session_id}): {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:
@@ -625,7 +625,7 @@ class MochatChannel(BaseChannel):
except asyncio.CancelledError:
break
except Exception as e:
- logger.warning(f"Mochat panel polling error ({panel_id}): {e}")
+ logger.warning("Mochat panel polling error ({}): {}", panel_id, e)
await asyncio.sleep(sleep_s)
# ---- inbound event processing ------------------------------------------
@@ -836,7 +836,7 @@ class MochatChannel(BaseChannel):
try:
data = json.loads(self._cursor_path.read_text("utf-8"))
except Exception as e:
- logger.warning(f"Failed to read Mochat cursor file: {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):
@@ -852,7 +852,7 @@ class MochatChannel(BaseChannel):
"cursors": self._session_cursor,
}, ensure_ascii=False, indent=2) + "\n", "utf-8")
except Exception as e:
- logger.warning(f"Failed to save Mochat cursor file: {e}")
+ logger.warning("Failed to save Mochat cursor file: {}", e)
# ---- HTTP helpers ------------------------------------------------------
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index 0e8fe66..1d00bc7 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -80,7 +80,7 @@ class QQChannel(BaseChannel):
try:
await self._client.start(appid=self.config.app_id, secret=self.config.secret)
except Exception as e:
- logger.warning(f"QQ bot error: {e}")
+ logger.warning("QQ bot error: {}", e)
if self._running:
logger.info("Reconnecting QQ bot in 5 seconds...")
await asyncio.sleep(5)
@@ -108,7 +108,7 @@ class QQChannel(BaseChannel):
content=msg.content,
)
except Exception as e:
- logger.error(f"Error sending QQ message: {e}")
+ logger.error("Error sending QQ message: {}", e)
async def _on_message(self, data: "C2CMessage") -> None:
"""Handle incoming message from QQ."""
@@ -131,4 +131,4 @@ class QQChannel(BaseChannel):
metadata={"message_id": data.id},
)
except Exception as e:
- logger.error(f"Error handling QQ message: {e}")
+ logger.error("Error handling QQ message: {}", e)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index dca5055..7dd2971 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -55,7 +55,7 @@ class SlackChannel(BaseChannel):
self._bot_user_id = auth.get("user_id")
logger.info(f"Slack bot connected as {self._bot_user_id}")
except Exception as e:
- logger.warning(f"Slack auth_test failed: {e}")
+ logger.warning("Slack auth_test failed: {}", e)
logger.info("Starting Slack Socket Mode client...")
await self._socket_client.connect()
@@ -70,7 +70,7 @@ class SlackChannel(BaseChannel):
try:
await self._socket_client.close()
except Exception as e:
- logger.warning(f"Slack socket close failed: {e}")
+ logger.warning("Slack socket close failed: {}", e)
self._socket_client = None
async def send(self, msg: OutboundMessage) -> None:
@@ -90,7 +90,7 @@ class SlackChannel(BaseChannel):
thread_ts=thread_ts if use_thread else None,
)
except Exception as e:
- logger.error(f"Error sending Slack message: {e}")
+ logger.error("Error sending Slack message: {}", e)
async def _on_socket_request(
self,
@@ -164,7 +164,7 @@ class SlackChannel(BaseChannel):
timestamp=event.get("ts"),
)
except Exception as e:
- logger.debug(f"Slack reactions_add failed: {e}")
+ logger.debug("Slack reactions_add failed: {}", e)
await self._handle_message(
sender_id=sender_id,
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 39924b3..42db489 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -171,7 +171,7 @@ class TelegramChannel(BaseChannel):
await self._app.bot.set_my_commands(self.BOT_COMMANDS)
logger.debug("Telegram bot commands registered")
except Exception as e:
- logger.warning(f"Failed to register bot commands: {e}")
+ logger.warning("Failed to register bot commands: {}", e)
# Start polling (this runs until stopped)
await self._app.updater.start_polling(
@@ -238,7 +238,7 @@ class TelegramChannel(BaseChannel):
await sender(chat_id=chat_id, **{param: f})
except Exception as e:
filename = media_path.rsplit("/", 1)[-1]
- logger.error(f"Failed to send media {media_path}: {e}")
+ 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}]")
# Send text content
@@ -248,11 +248,11 @@ class TelegramChannel(BaseChannel):
html = _markdown_to_telegram_html(chunk)
await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML")
except Exception as e:
- logger.warning(f"HTML parse failed, falling back to plain text: {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)
except Exception as e2:
- logger.error(f"Error sending Telegram message: {e2}")
+ logger.error("Error sending Telegram message: {}", e2)
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command."""
@@ -353,12 +353,12 @@ class TelegramChannel(BaseChannel):
logger.debug(f"Downloaded {media_type} to {file_path}")
except Exception as e:
- logger.error(f"Failed to download media: {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(f"Telegram message from {sender_id}: {content[:50]}...")
+ logger.debug("Telegram message from {}: {}...", sender_id, content[:50])
str_chat_id = str(chat_id)
@@ -401,11 +401,11 @@ class TelegramChannel(BaseChannel):
except asyncio.CancelledError:
pass
except Exception as e:
- logger.debug(f"Typing indicator stopped for {chat_id}: {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(f"Telegram error: {context.error}")
+ 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."""
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 0cf2dd7..4d12360 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -53,14 +53,14 @@ class WhatsAppChannel(BaseChannel):
try:
await self._handle_bridge_message(message)
except Exception as e:
- logger.error(f"Error handling bridge message: {e}")
+ logger.error("Error handling bridge message: {}", e)
except asyncio.CancelledError:
break
except Exception as e:
self._connected = False
self._ws = None
- logger.warning(f"WhatsApp bridge connection error: {e}")
+ logger.warning("WhatsApp bridge connection error: {}", e)
if self._running:
logger.info("Reconnecting in 5 seconds...")
@@ -89,14 +89,14 @@ class WhatsAppChannel(BaseChannel):
}
await self._ws.send(json.dumps(payload))
except Exception as e:
- logger.error(f"Error sending WhatsApp message: {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(f"Invalid JSON from bridge: {raw[:100]}")
+ logger.warning("Invalid JSON from bridge: {}", raw[:100])
return
msg_type = data.get("type")
@@ -145,4 +145,4 @@ class WhatsAppChannel(BaseChannel):
logger.info("Scan QR code in the bridge terminal to connect WhatsApp")
elif msg_type == "error":
- logger.error(f"WhatsApp bridge error: {data.get('error')}")
+ logger.error("WhatsApp bridge error: {}", data.get('error'))
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index 14666e8..d2b9ef7 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -99,7 +99,7 @@ class CronService:
))
self._store = CronStore(jobs=jobs)
except Exception as e:
- logger.warning(f"Failed to load cron store: {e}")
+ logger.warning("Failed to load cron store: {}", e)
self._store = CronStore()
else:
self._store = CronStore()
@@ -236,7 +236,7 @@ class CronService:
except Exception as e:
job.state.last_status = "error"
job.state.last_error = str(e)
- logger.error(f"Cron: job '{job.name}' failed: {e}")
+ logger.error("Cron: job '{}' failed: {}", job.name, e)
job.state.last_run_at_ms = start_ms
job.updated_at_ms = _now_ms()
diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py
index 221ed27..8bdc78f 100644
--- a/nanobot/heartbeat/service.py
+++ b/nanobot/heartbeat/service.py
@@ -97,7 +97,7 @@ class HeartbeatService:
except asyncio.CancelledError:
break
except Exception as e:
- logger.error(f"Heartbeat error: {e}")
+ logger.error("Heartbeat error: {}", e)
async def _tick(self) -> None:
"""Execute a single heartbeat tick."""
@@ -121,7 +121,7 @@ class HeartbeatService:
logger.info(f"Heartbeat: completed task")
except Exception as e:
- logger.error(f"Heartbeat execution failed: {e}")
+ logger.error("Heartbeat execution failed: {}", e)
async def trigger_now(self) -> str | None:
"""Manually trigger a heartbeat."""
diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py
index 8ce909b..eb5969d 100644
--- a/nanobot/providers/transcription.py
+++ b/nanobot/providers/transcription.py
@@ -61,5 +61,5 @@ class GroqTranscriptionProvider:
return data.get("text", "")
except Exception as e:
- logger.error(f"Groq transcription error: {e}")
+ logger.error("Groq transcription error: {}", e)
return ""
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 752fce4..44dcecb 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -144,7 +144,7 @@ class SessionManager:
last_consolidated=last_consolidated
)
except Exception as e:
- logger.warning(f"Failed to load session {key}: {e}")
+ logger.warning("Failed to load session {}: {}", key, e)
return None
def save(self, session: Session) -> None:
From afca0278ad9e99be7f2778fdbdcb1bb3aac2ecb0 Mon Sep 17 00:00:00 2001
From: Rudolfs Tilgass
Date: Thu, 19 Feb 2026 21:02:52 +0100
Subject: [PATCH 191/506] fix(memory): Enforce memory consolidation schema with
a tool call
---
nanobot/agent/loop.py | 111 +++++++++++++++++++++++-------------------
1 file changed, 62 insertions(+), 49 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index e5a5183..e9e225c 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -3,7 +3,6 @@
import asyncio
from contextlib import AsyncExitStack
import json
-import json_repair
from pathlib import Path
import re
from typing import Any, Awaitable, Callable
@@ -84,13 +83,13 @@ class AgentLoop:
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
-
+
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._register_default_tools()
-
+
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
# File tools (restrict to workspace if configured)
@@ -99,30 +98,30 @@ class AgentLoop:
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
self.tools.register(EditFileTool(allowed_dir=allowed_dir))
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
-
+
# Shell tool
self.tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
))
-
+
# Web tools
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
-
+
# Message tool
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
self.tools.register(message_tool)
-
+
# Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool)
-
+
# Cron tool (for scheduling)
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
-
+
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or not self._mcp_servers:
@@ -255,7 +254,7 @@ class AgentLoop:
))
except asyncio.TimeoutError:
continue
-
+
async def close_mcp(self) -> None:
"""Close MCP connections."""
if self._mcp_stack:
@@ -269,7 +268,7 @@ class AgentLoop:
"""Stop the agent loop."""
self._running = False
logger.info("Agent loop stopping")
-
+
async def _process_message(
self,
msg: InboundMessage,
@@ -278,25 +277,25 @@ class AgentLoop:
) -> OutboundMessage | None:
"""
Process a single inbound message.
-
+
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
on_progress: Optional callback for intermediate output (defaults to bus publish).
-
+
Returns:
The response message, or None if no response needed.
"""
# System messages route back via chat_id ("channel:chat_id")
if msg.channel == "system":
return await self._process_system_message(msg)
-
+
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
-
+
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
-
+
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
@@ -317,7 +316,7 @@ class AgentLoop:
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
-
+
if len(session.messages) > self.memory_window:
asyncio.create_task(self._consolidate_memory(session))
@@ -342,31 +341,31 @@ class AgentLoop:
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(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
-
+
session.add_message("user", msg.content)
session.add_message("assistant", final_content,
tools_used=tools_used if tools_used else None)
self.sessions.save(session)
-
+
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
-
+
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
"""
Process a system message (e.g., subagent announce).
-
+
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
logger.info(f"Processing system message from {msg.sender_id}")
-
+
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg.chat_id:
parts = msg.chat_id.split(":", 1)
@@ -376,7 +375,7 @@ class AgentLoop:
# Fallback
origin_channel = "cli"
origin_chat_id = msg.chat_id
-
+
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
self._set_tool_context(origin_channel, origin_chat_id)
@@ -390,17 +389,17 @@ class AgentLoop:
if final_content is None:
final_content = "Background task completed."
-
+
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self.sessions.save(session)
-
+
return OutboundMessage(
channel=origin_channel,
chat_id=origin_chat_id,
content=final_content
)
-
+
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
"""Consolidate old messages into MEMORY.md + HISTORY.md.
@@ -439,42 +438,56 @@ class AgentLoop:
conversation = "\n".join(lines)
current_memory = memory.read_long_term()
- prompt = f"""You are a memory consolidation agent. Process this conversation and return a JSON object with exactly two keys:
-
-1. "history_entry": A paragraph (2-5 sentences) summarizing the key events/decisions/topics. Start with a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.
-
-2. "memory_update": The updated long-term memory content. Add any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.
+ 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
-{conversation}
+{conversation}"""
-Respond with ONLY valid JSON, no markdown fences."""
+ 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 a timestamp like [YYYY-MM-DD HH:MM]. Include enough detail to be useful when found by grep search later.",
+ },
+ "memory_update": {
+ "type": "string",
+ "description": "The full updated long-term memory content as a markdown string. Include all existing facts plus any new facts: user location, preferences, personal info, habits, project context, technical decisions, tools/services used. If nothing new, return the existing content unchanged.",
+ },
+ },
+ "required": ["history_entry", "memory_update"],
+ },
+ },
+ }
+ ]
try:
response = await self.provider.chat(
messages=[
- {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
+ {"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=self.model,
)
- text = (response.content or "").strip()
- if not text:
- logger.warning("Memory consolidation: LLM returned empty response, skipping")
- return
- if text.startswith("```"):
- text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
- result = json_repair.loads(text)
- if not isinstance(result, dict):
- logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}")
+
+ if not response.has_tool_calls:
+ logger.warning("Memory consolidation: LLM did not call save_memory tool, skipping")
return
- if entry := result.get("history_entry"):
+ args = response.tool_calls[0].arguments
+ if entry := args.get("history_entry"):
memory.append_history(entry)
- if update := result.get("memory_update"):
+ if update := args.get("memory_update"):
if update != current_memory:
memory.write_long_term(update)
@@ -496,14 +509,14 @@ Respond with ONLY valid JSON, no markdown fences."""
) -> str:
"""
Process a message directly (for CLI or cron usage).
-
+
Args:
content: The message content.
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
on_progress: Optional callback for intermediate output.
-
+
Returns:
The agent's response.
"""
@@ -514,6 +527,6 @@ Respond with ONLY valid JSON, no markdown fences."""
chat_id=chat_id,
content=content
)
-
+
response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""
From f3c7337356de507b6a1e0b10725bc2521f48ec95 Mon Sep 17 00:00:00 2001
From: dxtime
Date: Fri, 20 Feb 2026 08:31:52 +0800
Subject: [PATCH 192/506] feat: Added custom headers for MCP Auth use, update
README.md
---
README.md | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 7fad9ce..e8aac1e 100644
--- a/README.md
+++ b/README.md
@@ -752,7 +752,14 @@ Add MCP servers to your `config.json`:
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
}
- }
+ },
+ "urlMcpServers": {
+ "url": "https://xx.xx.xx.xx:xxxx/mcp/",
+ "headers": {
+ "Authorization": "Bearer xxxxx",
+ "X-API-Key": "xxxxxxx"
+ }
+ },
}
}
```
@@ -762,7 +769,7 @@ Two transport modes are supported:
| Mode | Config | Example |
|------|--------|---------|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
-| **HTTP** | `url` | Remote endpoint (`https://mcp.example.com/sse`) |
+| **HTTP** | `url` + `option(headers)`| Remote endpoint (`https://mcp.example.com/sse`) |
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
From 0001f286b578b6ecf9634b47c6605978038e5a61 Mon Sep 17 00:00:00 2001
From: AlexanderMerkel
Date: Thu, 19 Feb 2026 19:00:25 -0700
Subject: [PATCH 193/506] fix: remove dead pub/sub code from MessageBus
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`subscribe_outbound()`, `dispatch_outbound()`, and `stop()` have zero
callers — `ChannelManager._dispatch_outbound()` handles all outbound
routing via `consume_outbound()` directly. Remove the dead methods and
their unused imports (`Callable`, `Awaitable`, `logger`).
Co-Authored-By: Claude Opus 4.6
---
nanobot/bus/queue.py | 53 +++++++-------------------------------------
1 file changed, 8 insertions(+), 45 deletions(-)
diff --git a/nanobot/bus/queue.py b/nanobot/bus/queue.py
index 4123d06..7c0616f 100644
--- a/nanobot/bus/queue.py
+++ b/nanobot/bus/queue.py
@@ -1,9 +1,6 @@
"""Async message queue for decoupled channel-agent communication."""
import asyncio
-from typing import Callable, Awaitable
-
-from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage
@@ -11,70 +8,36 @@ from nanobot.bus.events import InboundMessage, OutboundMessage
class MessageBus:
"""
Async message bus that decouples chat channels from the agent core.
-
+
Channels push messages to the inbound queue, and the agent processes
them and pushes responses to the outbound queue.
"""
-
+
def __init__(self):
self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue()
self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue()
- self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {}
- self._running = False
-
+
async def publish_inbound(self, msg: InboundMessage) -> None:
"""Publish a message from a channel to the agent."""
await self.inbound.put(msg)
-
+
async def consume_inbound(self) -> InboundMessage:
"""Consume the next inbound message (blocks until available)."""
return await self.inbound.get()
-
+
async def publish_outbound(self, msg: OutboundMessage) -> None:
"""Publish a response from the agent to channels."""
await self.outbound.put(msg)
-
+
async def consume_outbound(self) -> OutboundMessage:
"""Consume the next outbound message (blocks until available)."""
return await self.outbound.get()
-
- def subscribe_outbound(
- self,
- channel: str,
- callback: Callable[[OutboundMessage], Awaitable[None]]
- ) -> None:
- """Subscribe to outbound messages for a specific channel."""
- if channel not in self._outbound_subscribers:
- self._outbound_subscribers[channel] = []
- self._outbound_subscribers[channel].append(callback)
-
- async def dispatch_outbound(self) -> None:
- """
- Dispatch outbound messages to subscribed channels.
- Run this as a background task.
- """
- self._running = True
- while self._running:
- try:
- msg = await asyncio.wait_for(self.outbound.get(), timeout=1.0)
- subscribers = self._outbound_subscribers.get(msg.channel, [])
- for callback in subscribers:
- try:
- await callback(msg)
- except Exception as e:
- logger.error(f"Error dispatching to {msg.channel}: {e}")
- except asyncio.TimeoutError:
- continue
-
- def stop(self) -> None:
- """Stop the dispatcher loop."""
- self._running = False
-
+
@property
def inbound_size(self) -> int:
"""Number of pending inbound messages."""
return self.inbound.qsize()
-
+
@property
def outbound_size(self) -> int:
"""Number of pending outbound messages."""
From 0d3dc57a65e54bd8223110da4e7d5ac06b5aa924 Mon Sep 17 00:00:00 2001
From: Tanish Rajput
Date: Mon, 9 Feb 2026 22:57:38 +0530
Subject: [PATCH 194/506] feat: add matrix (Element) chat channel support
---
nanobot/channels/matrix.py | 79 ++++++++++++++++++++++++++++++++++++++
1 file changed, 79 insertions(+)
create mode 100644 nanobot/channels/matrix.py
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
new file mode 100644
index 0000000..0ea4ae1
--- /dev/null
+++ b/nanobot/channels/matrix.py
@@ -0,0 +1,79 @@
+import asyncio
+from typing import Any
+
+from nio import AsyncClient, MatrixRoom, RoomMessageText
+
+from nanobot.channels.base import BaseChannel
+from nanobot.bus.events import OutboundMessage
+
+
+class MatrixChannel(BaseChannel):
+ """
+ Matrix (Element) channel using long-polling sync.
+ """
+
+ name = "matrix"
+
+ def __init__(self, config: Any, bus):
+ super().__init__(config, bus)
+ self.client: AsyncClient | None = None
+ self._sync_task: asyncio.Task | None = None
+
+ async def start(self) -> None:
+ self._running = True
+
+ self.client = AsyncClient(
+ homeserver=self.config.homeserver,
+ user=self.config.user_id,
+ )
+
+ self.client.access_token = self.config.access_token
+
+ self.client.add_event_callback(
+ self._on_message,
+ RoomMessageText
+ )
+
+ self._sync_task = asyncio.create_task(self._sync_loop())
+
+ async def stop(self) -> None:
+ self._running = False
+ if self._sync_task:
+ self._sync_task.cancel()
+ if self.client:
+ await self.client.close()
+
+ async def send(self, msg: OutboundMessage) -> None:
+ if not self.client:
+ return
+
+ await self.client.room_send(
+ room_id=msg.chat_id,
+ message_type="m.room.message",
+ content={"msgtype": "m.text", "body": msg.content},
+ )
+
+ async def _sync_loop(self) -> None:
+ while self._running:
+ try:
+ await self.client.sync(timeout=30000)
+ except asyncio.CancelledError:
+ break
+ except Exception:
+ await asyncio.sleep(2)
+
+ async def _on_message(
+ self,
+ room: MatrixRoom,
+ event: RoomMessageText
+ ) -> None:
+ # Ignore self messages
+ if event.sender == self.config.user_id:
+ return
+
+ await self._handle_message(
+ sender_id=event.sender,
+ chat_id=room.room_id,
+ content=event.body,
+ metadata={"room": room.display_name},
+ )
\ No newline at end of file
From 37252a4226214367abea1cef0fae4591b2dc00c4 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 07:55:34 +0000
Subject: [PATCH 195/506] fix: complete loguru native formatting migration
across all files
---
nanobot/agent/loop.py | 12 ++++++------
nanobot/agent/subagent.py | 8 ++++----
nanobot/agent/tools/mcp.py | 6 +++---
nanobot/channels/dingtalk.py | 2 +-
nanobot/channels/discord.py | 2 +-
nanobot/channels/email.py | 2 +-
nanobot/channels/feishu.py | 10 +++++-----
nanobot/channels/manager.py | 24 ++++++++++++------------
nanobot/channels/qq.py | 2 +-
nanobot/channels/slack.py | 4 ++--
nanobot/channels/telegram.py | 8 ++++----
nanobot/channels/whatsapp.py | 8 ++++----
nanobot/cron/service.py | 10 +++++-----
nanobot/heartbeat/service.py | 4 ++--
nanobot/providers/transcription.py | 2 +-
nanobot/session/manager.py | 2 +-
16 files changed, 53 insertions(+), 53 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index cbab5aa..1620cb0 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -365,7 +365,7 @@ class AgentLoop:
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
- logger.info(f"Processing system message from {msg.sender_id}")
+ logger.info("Processing system message from {}", msg.sender_id)
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg.chat_id:
@@ -413,22 +413,22 @@ class AgentLoop:
if archive_all:
old_messages = session.messages
keep_count = 0
- logger.info(f"Memory consolidation (archive_all): {len(session.messages)} total messages archived")
+ logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages))
else:
keep_count = self.memory_window // 2
if len(session.messages) <= keep_count:
- logger.debug(f"Session {session.key}: No consolidation needed (messages={len(session.messages)}, keep={keep_count})")
+ logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count)
return
messages_to_process = len(session.messages) - session.last_consolidated
if messages_to_process <= 0:
- logger.debug(f"Session {session.key}: No new messages to consolidate (last_consolidated={session.last_consolidated}, total={len(session.messages)})")
+ logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages))
return
old_messages = session.messages[session.last_consolidated:-keep_count]
if not old_messages:
return
- logger.info(f"Memory consolidation started: {len(session.messages)} total, {len(old_messages)} new to consolidate, {keep_count} keep")
+ logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count)
lines = []
for m in old_messages:
@@ -482,7 +482,7 @@ Respond with ONLY valid JSON, no markdown fences."""
session.last_consolidated = 0
else:
session.last_consolidated = len(session.messages) - keep_count
- logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}")
+ logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
except Exception as e:
logger.error("Memory consolidation failed: {}", e)
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index ae0e492..7d48cc4 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -86,7 +86,7 @@ class SubagentManager:
# Cleanup when done
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
- logger.info(f"Spawned subagent [{task_id}]: {display_label}")
+ logger.info("Spawned subagent [{}]: {}", task_id, display_label)
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
async def _run_subagent(
@@ -97,7 +97,7 @@ class SubagentManager:
origin: dict[str, str],
) -> None:
"""Execute the subagent task and announce the result."""
- logger.info(f"Subagent [{task_id}] starting task: {label}")
+ logger.info("Subagent [{}] starting task: {}", task_id, label)
try:
# Build subagent tools (no message tool, no spawn tool)
@@ -175,7 +175,7 @@ class SubagentManager:
if final_result is None:
final_result = "Task completed but no final response was generated."
- logger.info(f"Subagent [{task_id}] completed successfully")
+ logger.info("Subagent [{}] completed successfully", task_id)
await self._announce_result(task_id, label, task, final_result, origin, "ok")
except Exception as e:
@@ -213,7 +213,7 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
)
await self.bus.publish_inbound(msg)
- logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}")
+ logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
def _build_subagent_prompt(self, task: str) -> str:
"""Build a focused system prompt for the subagent."""
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index 4e61923..7d9033d 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -63,7 +63,7 @@ async def connect_mcp_servers(
streamable_http_client(cfg.url)
)
else:
- logger.warning(f"MCP server '{name}': no command or url configured, skipping")
+ logger.warning("MCP server '{}': no command or url configured, skipping", name)
continue
session = await stack.enter_async_context(ClientSession(read, write))
@@ -73,8 +73,8 @@ async def connect_mcp_servers(
for tool_def in tools.tools:
wrapper = MCPToolWrapper(session, name, tool_def)
registry.register(wrapper)
- logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
+ logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
- logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
+ logger.info("MCP server '{}': connected, {} tools registered", name, len(tools.tools))
except Exception as e:
logger.error("MCP server '{}': failed to connect: {}", name, e)
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 3ac233f..f6dca30 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -220,7 +220,7 @@ class DingTalkChannel(BaseChannel):
if resp.status_code != 200:
logger.error("DingTalk send failed: {}", resp.text)
else:
- logger.debug(f"DingTalk message sent to {msg.chat_id}")
+ logger.debug("DingTalk message sent to {}", msg.chat_id)
except Exception as e:
logger.error("Error sending DingTalk message: {}", e)
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index ee54eed..8baecbf 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -94,7 +94,7 @@ class DiscordChannel(BaseChannel):
if response.status_code == 429:
data = response.json()
retry_after = float(data.get("retry_after", 1.0))
- logger.warning(f"Discord rate limited, retrying in {retry_after}s")
+ logger.warning("Discord rate limited, retrying in {}s", retry_after)
await asyncio.sleep(retry_after)
continue
response.raise_for_status()
diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py
index 8a1ee79..1b6f46b 100644
--- a/nanobot/channels/email.py
+++ b/nanobot/channels/email.py
@@ -162,7 +162,7 @@ class EmailChannel(BaseChannel):
missing.append("smtp_password")
if missing:
- logger.error(f"Email channel not configured, missing: {', '.join(missing)}")
+ logger.error("Email channel not configured, missing: {}", ', '.join(missing))
return False
return True
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 6f62202..c17bf1a 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -196,7 +196,7 @@ class FeishuChannel(BaseChannel):
if not response.success():
logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg)
else:
- logger.debug(f"Added {emoji_type} reaction to message {message_id}")
+ logger.debug("Added {} reaction to message {}", emoji_type, message_id)
except Exception as e:
logger.warning("Error adding reaction: {}", e)
@@ -309,7 +309,7 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.image.create(request)
if response.success():
image_key = response.data.image_key
- logger.debug(f"Uploaded image {os.path.basename(file_path)}: {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)
@@ -336,7 +336,7 @@ class FeishuChannel(BaseChannel):
response = self._client.im.v1.file.create(request)
if response.success():
file_key = response.data.file_key
- logger.debug(f"Uploaded file {file_name}: {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)
@@ -364,7 +364,7 @@ class FeishuChannel(BaseChannel):
msg_type, response.code, response.msg, response.get_log_id()
)
return False
- logger.debug(f"Feishu {msg_type} message sent to {receive_id}")
+ 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)
@@ -382,7 +382,7 @@ class FeishuChannel(BaseChannel):
for file_path in msg.media:
if not os.path.isfile(file_path):
- logger.warning(f"Media file not found: {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:
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 3e714c3..6fbab04 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -45,7 +45,7 @@ class ChannelManager:
)
logger.info("Telegram channel enabled")
except ImportError as e:
- logger.warning(f"Telegram channel not available: {e}")
+ logger.warning("Telegram channel not available: {}", e)
# WhatsApp channel
if self.config.channels.whatsapp.enabled:
@@ -56,7 +56,7 @@ class ChannelManager:
)
logger.info("WhatsApp channel enabled")
except ImportError as e:
- logger.warning(f"WhatsApp channel not available: {e}")
+ logger.warning("WhatsApp channel not available: {}", e)
# Discord channel
if self.config.channels.discord.enabled:
@@ -67,7 +67,7 @@ class ChannelManager:
)
logger.info("Discord channel enabled")
except ImportError as e:
- logger.warning(f"Discord channel not available: {e}")
+ logger.warning("Discord channel not available: {}", e)
# Feishu channel
if self.config.channels.feishu.enabled:
@@ -78,7 +78,7 @@ class ChannelManager:
)
logger.info("Feishu channel enabled")
except ImportError as e:
- logger.warning(f"Feishu channel not available: {e}")
+ logger.warning("Feishu channel not available: {}", e)
# Mochat channel
if self.config.channels.mochat.enabled:
@@ -90,7 +90,7 @@ class ChannelManager:
)
logger.info("Mochat channel enabled")
except ImportError as e:
- logger.warning(f"Mochat channel not available: {e}")
+ logger.warning("Mochat channel not available: {}", e)
# DingTalk channel
if self.config.channels.dingtalk.enabled:
@@ -101,7 +101,7 @@ class ChannelManager:
)
logger.info("DingTalk channel enabled")
except ImportError as e:
- logger.warning(f"DingTalk channel not available: {e}")
+ logger.warning("DingTalk channel not available: {}", e)
# Email channel
if self.config.channels.email.enabled:
@@ -112,7 +112,7 @@ class ChannelManager:
)
logger.info("Email channel enabled")
except ImportError as e:
- logger.warning(f"Email channel not available: {e}")
+ logger.warning("Email channel not available: {}", e)
# Slack channel
if self.config.channels.slack.enabled:
@@ -123,7 +123,7 @@ class ChannelManager:
)
logger.info("Slack channel enabled")
except ImportError as e:
- logger.warning(f"Slack channel not available: {e}")
+ logger.warning("Slack channel not available: {}", e)
# QQ channel
if self.config.channels.qq.enabled:
@@ -135,7 +135,7 @@ class ChannelManager:
)
logger.info("QQ channel enabled")
except ImportError as e:
- logger.warning(f"QQ channel not available: {e}")
+ logger.warning("QQ channel not available: {}", e)
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""Start a channel and log any exceptions."""
@@ -156,7 +156,7 @@ class ChannelManager:
# Start channels
tasks = []
for name, channel in self.channels.items():
- logger.info(f"Starting {name} channel...")
+ logger.info("Starting {} channel...", name)
tasks.append(asyncio.create_task(self._start_channel(name, channel)))
# Wait for all to complete (they should run forever)
@@ -178,7 +178,7 @@ class ChannelManager:
for name, channel in self.channels.items():
try:
await channel.stop()
- logger.info(f"Stopped {name} channel")
+ logger.info("Stopped {} channel", name)
except Exception as e:
logger.error("Error stopping {}: {}", name, e)
@@ -200,7 +200,7 @@ class ChannelManager:
except Exception as e:
logger.error("Error sending to {}: {}", msg.channel, e)
else:
- logger.warning(f"Unknown channel: {msg.channel}")
+ logger.warning("Unknown channel: {}", msg.channel)
except asyncio.TimeoutError:
continue
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index 1d00bc7..16cbfb8 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -34,7 +34,7 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
super().__init__(intents=intents)
async def on_ready(self):
- logger.info(f"QQ bot ready: {self.robot.name}")
+ logger.info("QQ bot ready: {}", self.robot.name)
async def on_c2c_message_create(self, message: "C2CMessage"):
await channel._on_message(message)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index 7dd2971..79cbe76 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -36,7 +36,7 @@ class SlackChannel(BaseChannel):
logger.error("Slack bot/app token not configured")
return
if self.config.mode != "socket":
- logger.error(f"Unsupported Slack mode: {self.config.mode}")
+ logger.error("Unsupported Slack mode: {}", self.config.mode)
return
self._running = True
@@ -53,7 +53,7 @@ class SlackChannel(BaseChannel):
try:
auth = await self._web_client.auth_test()
self._bot_user_id = auth.get("user_id")
- logger.info(f"Slack bot connected as {self._bot_user_id}")
+ logger.info("Slack bot connected as {}", self._bot_user_id)
except Exception as e:
logger.warning("Slack auth_test failed: {}", e)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 42db489..fa36c98 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -165,7 +165,7 @@ class TelegramChannel(BaseChannel):
# Get bot info and register command menu
bot_info = await self._app.bot.get_me()
- logger.info(f"Telegram bot @{bot_info.username} connected")
+ logger.info("Telegram bot @{} connected", bot_info.username)
try:
await self._app.bot.set_my_commands(self.BOT_COMMANDS)
@@ -221,7 +221,7 @@ class TelegramChannel(BaseChannel):
try:
chat_id = int(msg.chat_id)
except ValueError:
- logger.error(f"Invalid chat_id: {msg.chat_id}")
+ logger.error("Invalid chat_id: {}", msg.chat_id)
return
# Send media files
@@ -344,14 +344,14 @@ class TelegramChannel(BaseChannel):
transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
transcription = await transcriber.transcribe(file_path)
if transcription:
- logger.info(f"Transcribed {media_type}: {transcription[:50]}...")
+ 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(f"Downloaded {media_type} to {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]")
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 4d12360..f3e14d9 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -34,7 +34,7 @@ class WhatsAppChannel(BaseChannel):
bridge_url = self.config.bridge_url
- logger.info(f"Connecting to WhatsApp bridge at {bridge_url}...")
+ logger.info("Connecting to WhatsApp bridge at {}...", bridge_url)
self._running = True
@@ -112,11 +112,11 @@ class WhatsAppChannel(BaseChannel):
# 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(f"Sender {sender}")
+ logger.info("Sender {}", sender)
# Handle voice transcription if it's a voice message
if content == "[Voice Message]":
- logger.info(f"Voice message received from {sender_id}, but direct download from bridge is not yet supported.")
+ 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(
@@ -133,7 +133,7 @@ class WhatsAppChannel(BaseChannel):
elif msg_type == "status":
# Connection status update
status = data.get("status")
- logger.info(f"WhatsApp status: {status}")
+ logger.info("WhatsApp status: {}", status)
if status == "connected":
self._connected = True
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index d2b9ef7..4c14ef7 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -157,7 +157,7 @@ class CronService:
self._recompute_next_runs()
self._save_store()
self._arm_timer()
- logger.info(f"Cron service started with {len(self._store.jobs if self._store else [])} jobs")
+ logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else []))
def stop(self) -> None:
"""Stop the cron service."""
@@ -222,7 +222,7 @@ class CronService:
async def _execute_job(self, job: CronJob) -> None:
"""Execute a single job."""
start_ms = _now_ms()
- logger.info(f"Cron: executing job '{job.name}' ({job.id})")
+ logger.info("Cron: executing job '{}' ({})", job.name, job.id)
try:
response = None
@@ -231,7 +231,7 @@ class CronService:
job.state.last_status = "ok"
job.state.last_error = None
- logger.info(f"Cron: job '{job.name}' completed")
+ logger.info("Cron: job '{}' completed", job.name)
except Exception as e:
job.state.last_status = "error"
@@ -296,7 +296,7 @@ class CronService:
self._save_store()
self._arm_timer()
- logger.info(f"Cron: added job '{name}' ({job.id})")
+ logger.info("Cron: added job '{}' ({})", name, job.id)
return job
def remove_job(self, job_id: str) -> bool:
@@ -309,7 +309,7 @@ class CronService:
if removed:
self._save_store()
self._arm_timer()
- logger.info(f"Cron: removed job {job_id}")
+ logger.info("Cron: removed job {}", job_id)
return removed
diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py
index 8bdc78f..8b33e3a 100644
--- a/nanobot/heartbeat/service.py
+++ b/nanobot/heartbeat/service.py
@@ -78,7 +78,7 @@ class HeartbeatService:
self._running = True
self._task = asyncio.create_task(self._run_loop())
- logger.info(f"Heartbeat started (every {self.interval_s}s)")
+ logger.info("Heartbeat started (every {}s)", self.interval_s)
def stop(self) -> None:
"""Stop the heartbeat service."""
@@ -118,7 +118,7 @@ class HeartbeatService:
if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""):
logger.info("Heartbeat: OK (no action needed)")
else:
- logger.info(f"Heartbeat: completed task")
+ logger.info("Heartbeat: completed task")
except Exception as e:
logger.error("Heartbeat execution failed: {}", e)
diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py
index eb5969d..7a3c628 100644
--- a/nanobot/providers/transcription.py
+++ b/nanobot/providers/transcription.py
@@ -35,7 +35,7 @@ class GroqTranscriptionProvider:
path = Path(file_path)
if not path.exists():
- logger.error(f"Audio file not found: {file_path}")
+ logger.error("Audio file not found: {}", file_path)
return ""
try:
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 44dcecb..9c0c7de 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -110,7 +110,7 @@ class SessionManager:
if legacy_path.exists():
import shutil
shutil.move(str(legacy_path), str(path))
- logger.info(f"Migrated session {key} from legacy path")
+ logger.info("Migrated session {} from legacy path", key)
if not path.exists():
return None
From e17342ddfc812a629d54e4be28f7cb39a84be424 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 08:03:24 +0000
Subject: [PATCH 196/506] fix: pass workspace to file tools in subagent
---
nanobot/agent/subagent.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 767bc68..d87c61a 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -103,10 +103,10 @@ class SubagentManager:
# Build subagent tools (no message tool, no spawn tool)
tools = ToolRegistry()
allowed_dir = self.workspace if self.restrict_to_workspace else None
- tools.register(ReadFileTool(allowed_dir=allowed_dir))
- tools.register(WriteFileTool(allowed_dir=allowed_dir))
- tools.register(EditFileTool(allowed_dir=allowed_dir))
- tools.register(ListDirTool(allowed_dir=allowed_dir))
+ tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
+ tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
+ tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
+ tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
From 002de466d769ffa81a0fa89ff8056f0eb9cdc5fd Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 08:12:23 +0000
Subject: [PATCH 197/506] chore: remove test file for memory consolidation fix
---
README.md | 2 +-
tests/test_memory_consolidation_types.py | 133 -----------------------
2 files changed, 1 insertion(+), 134 deletions(-)
delete mode 100644 tests/test_memory_consolidation_types.py
diff --git a/README.md b/README.md
index a474367..289ff28 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,761 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,781 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py
deleted file mode 100644
index 3b76596..0000000
--- a/tests/test_memory_consolidation_types.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""Test memory consolidation handles non-string values from LLM.
-
-This test verifies the fix for the bug where memory consolidation fails
-when LLM returns JSON objects instead of strings for history_entry or
-memory_update fields.
-
-Related issue: Memory consolidation fails with TypeError when LLM returns dict
-"""
-
-import json
-import tempfile
-from pathlib import Path
-
-import pytest
-
-from nanobot.agent.memory import MemoryStore
-
-
-class TestMemoryConsolidationTypeHandling:
- """Test that MemoryStore methods handle type conversion correctly."""
-
- def test_append_history_accepts_string(self):
- """MemoryStore.append_history should accept string values."""
- with tempfile.TemporaryDirectory() as tmpdir:
- memory = MemoryStore(Path(tmpdir))
-
- # Should not raise TypeError
- memory.append_history("[2026-02-14] Test entry")
-
- # Verify content was written
- history_content = memory.history_file.read_text()
- assert "Test entry" in history_content
-
- def test_write_long_term_accepts_string(self):
- """MemoryStore.write_long_term should accept string values."""
- with tempfile.TemporaryDirectory() as tmpdir:
- memory = MemoryStore(Path(tmpdir))
-
- # Should not raise TypeError
- memory.write_long_term("- Fact 1\n- Fact 2")
-
- # Verify content was written
- memory_content = memory.read_long_term()
- assert "Fact 1" in memory_content
-
- def test_type_conversion_dict_to_str(self):
- """Dict values should be converted to JSON strings."""
- input_val = {"timestamp": "2026-02-14", "summary": "test"}
- expected = '{"timestamp": "2026-02-14", "summary": "test"}'
-
- # Simulate the fix logic
- if not isinstance(input_val, str):
- result = json.dumps(input_val, ensure_ascii=False)
- else:
- result = input_val
-
- assert result == expected
- assert isinstance(result, str)
-
- def test_type_conversion_list_to_str(self):
- """List values should be converted to JSON strings."""
- input_val = ["item1", "item2"]
- expected = '["item1", "item2"]'
-
- # Simulate the fix logic
- if not isinstance(input_val, str):
- result = json.dumps(input_val, ensure_ascii=False)
- else:
- result = input_val
-
- assert result == expected
- assert isinstance(result, str)
-
- def test_type_conversion_str_unchanged(self):
- """String values should remain unchanged."""
- input_val = "already a string"
-
- # Simulate the fix logic
- if not isinstance(input_val, str):
- result = json.dumps(input_val, ensure_ascii=False)
- else:
- result = input_val
-
- assert result == input_val
- assert isinstance(result, str)
-
- def test_memory_consolidation_simulation(self):
- """Simulate full consolidation with dict values from LLM."""
- with tempfile.TemporaryDirectory() as tmpdir:
- memory = MemoryStore(Path(tmpdir))
-
- # Simulate LLM returning dict values (the bug scenario)
- history_entry = {"timestamp": "2026-02-14", "summary": "User asked about..."}
- memory_update = {"facts": ["Location: Beijing", "Skill: Python"]}
-
- # Apply the fix: convert to str
- if not isinstance(history_entry, str):
- history_entry = json.dumps(history_entry, ensure_ascii=False)
- if not isinstance(memory_update, str):
- memory_update = json.dumps(memory_update, ensure_ascii=False)
-
- # Should not raise TypeError after conversion
- memory.append_history(history_entry)
- memory.write_long_term(memory_update)
-
- # Verify content
- assert memory.history_file.exists()
- assert memory.memory_file.exists()
-
- history_content = memory.history_file.read_text()
- memory_content = memory.read_long_term()
-
- assert "timestamp" in history_content
- assert "facts" in memory_content
-
-
-class TestPromptOptimization:
- """Test that prompt optimization helps prevent the issue."""
-
- def test_prompt_includes_string_requirement(self):
- """The prompt should explicitly require string values."""
- # This is a documentation test - verify the fix is in place
- # by checking the expected prompt content
- expected_keywords = [
- "MUST be strings",
- "not objects or arrays",
- "Example:",
- ]
-
- # The actual prompt content is in nanobot/agent/loop.py
- # This test serves as documentation of the expected behavior
- for keyword in expected_keywords:
- assert keyword, f"Prompt should include: {keyword}"
From 9ffae47c13862c0942b16016e67725af199a5ace Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 08:21:02 +0000
Subject: [PATCH 198/506] refactor(litellm): remove redundant comments in
cache_control methods
---
nanobot/providers/litellm_provider.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 08c2f53..66751ed 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -117,7 +117,6 @@ class LiteLLMProvider(LLMProvider):
tools: list[dict[str, Any]] | None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]] | None]:
"""Return copies of messages and tools with cache_control injected."""
- # Transform the system message
new_messages = []
for msg in messages:
if msg.get("role") == "system":
@@ -131,7 +130,6 @@ class LiteLLMProvider(LLMProvider):
else:
new_messages.append(msg)
- # Add cache_control to the last tool definition
new_tools = tools
if tools:
new_tools = list(tools)
From 2383dcb3a82868162a970b91528945a84467af93 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 08:31:48 +0000
Subject: [PATCH 199/506] style: use loguru native format and trim comments in
interim retry
---
README.md | 2 +-
nanobot/agent/loop.py | 11 +++--------
2 files changed, 4 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 289ff28..21c5491 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,781 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,793 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 3829626..a90dccb 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -227,16 +227,11 @@ class AgentLoop:
)
else:
final_content = self._strip_think(response.content)
- # Some models (MiniMax, Gemini Flash, GPT-4.1, etc.) send an
- # interim text response (e.g. "Let me investigate...") before
- # making tool calls. If no tools have been used yet and we
- # haven't already retried, add the text to the conversation
- # and give the model one more chance to use tools.
- # We do NOT forward the interim text as progress to avoid
- # duplicate messages when the model simply answers directly.
+ # Some models send an interim text response before tool calls.
+ # Give them one retry; don't forward the text to avoid duplicates.
if not tools_used and not text_only_retried and final_content:
text_only_retried = True
- logger.debug(f"Interim text response (no tools used yet), retrying: {final_content[:80]}")
+ logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80])
messages = self.context.add_assistant_message(
messages, response.content,
reasoning_content=response.reasoning_content,
From 2f315ec567ab2432ab32332e3fa671a1bdc44dc6 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 08:39:26 +0000
Subject: [PATCH 200/506] style: trim _on_help docstring
---
nanobot/channels/telegram.py | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index af161d4..768e565 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -267,11 +267,7 @@ class TelegramChannel(BaseChannel):
)
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle /help command directly, bypassing access control.
-
- /help is informational and should be accessible to all users,
- even those not in the allowFrom list.
- """
+ """Handle /help command, bypassing ACL so all users can access it."""
if not update.message:
return
await update.message.reply_text(
From 25efd1bc543d2de31bb9f347b8fa168512b7dfde Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 08:45:42 +0000
Subject: [PATCH 201/506] docs: update docs for providers
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index dc85b54..c0bd4f4 100644
--- a/README.md
+++ b/README.md
@@ -591,7 +591,7 @@ Config file: `~/.nanobot/config.json`
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `minimax` | LLM (MiniMax direct) | [platform.minimax.io](https://platform.minimax.io) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
-| `siliconflow` | LLM (SiliconFlow/硅基流动, API gateway) | [siliconflow.cn](https://siliconflow.cn) |
+| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `volcengine` | LLM (VolcEngine/火山引擎) | [volcengine.com](https://www.volcengine.com) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
From f5fe74f5789be04028a03ed8c95c9f35feb2fd81 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 08:49:49 +0000
Subject: [PATCH 202/506] style: move httpx import to top-level and fix README
example for MCP headers
---
README.md | 17 ++++++++---------
nanobot/agent/tools/mcp.py | 2 +-
2 files changed, 9 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index ece47e0..b37f8bd 100644
--- a/README.md
+++ b/README.md
@@ -753,15 +753,14 @@ Add MCP servers to your `config.json`:
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
- }
- },
- "urlMcpServers": {
- "url": "https://xx.xx.xx.xx:xxxx/mcp/",
- "headers": {
- "Authorization": "Bearer xxxxx",
- "X-API-Key": "xxxxxxx"
- }
},
+ "my-remote-mcp": {
+ "url": "https://example.com/mcp/",
+ "headers": {
+ "Authorization": "Bearer xxxxx"
+ }
+ }
+ }
}
}
```
@@ -771,7 +770,7 @@ Two transport modes are supported:
| Mode | Config | Example |
|------|--------|---------|
| **Stdio** | `command` + `args` | Local process via `npx` / `uvx` |
-| **HTTP** | `url` + `option(headers)`| Remote endpoint (`https://mcp.example.com/sse`) |
+| **HTTP** | `url` + `headers` (optional) | Remote endpoint (`https://mcp.example.com/sse`) |
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index a02f42b..ad352bf 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -3,6 +3,7 @@
from contextlib import AsyncExitStack
from typing import Any
+import httpx
from loguru import logger
from nanobot.agent.tools.base import Tool
@@ -59,7 +60,6 @@ async def connect_mcp_servers(
read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url:
from mcp.client.streamable_http import streamable_http_client
- import httpx
if cfg.headers:
http_client = await stack.enter_async_context(
httpx.AsyncClient(
From b97b1a5e91bd8778e5625b2debc48c49998f0ada Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 09:04:33 +0000
Subject: [PATCH 203/506] fix: pass full agent config including mcp_servers to
cron run command
---
nanobot/cli/commands.py | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 9389d0b..a135349 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -864,11 +864,12 @@ def cron_run(
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
):
"""Manually run a job."""
+ from loguru import logger
from nanobot.config.loader import load_config, get_data_dir
from nanobot.cron.service import CronService
+ from nanobot.cron.types import CronJob
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
- from loguru import logger
logger.disable("nanobot")
config = load_config()
@@ -879,10 +880,14 @@ def cron_run(
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,
restrict_to_workspace=config.tools.restrict_to_workspace,
+ mcp_servers=config.tools.mcp_servers,
)
store_path = get_data_dir() / "cron" / "jobs.json"
@@ -890,7 +895,7 @@ def cron_run(
result_holder = []
- async def on_job(job):
+ async def on_job(job: CronJob) -> str | None:
response = await agent_loop.process_direct(
job.payload.message,
session_key=f"cron:{job.id}",
From e39bbaa9be85e57020be9735051e5f8044f53ed1 Mon Sep 17 00:00:00 2001
From: Paul
Date: Fri, 20 Feb 2026 09:54:21 +0000
Subject: [PATCH 204/506] feat(slack): add media file upload support
Use files_upload_v2 API to upload media attachments in Slack messages.
This enables the message tool's media parameter to work correctly
when sending images or other files through the Slack channel.
Requires files:write OAuth scope.
---
nanobot/channels/slack.py | 25 ++++++++++++++++++++-----
1 file changed, 20 insertions(+), 5 deletions(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index dca5055..d29f1e1 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -84,11 +84,26 @@ class SlackChannel(BaseChannel):
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"
- await self._web_client.chat_postMessage(
- channel=msg.chat_id,
- text=self._to_mrkdwn(msg.content),
- thread_ts=thread_ts if use_thread else None,
- )
+ thread_ts_param = thread_ts if use_thread else None
+
+ # Send text message if content is present
+ if msg.content:
+ await self._web_client.chat_postMessage(
+ channel=msg.chat_id,
+ text=self._to_mrkdwn(msg.content),
+ thread_ts=thread_ts_param,
+ )
+
+ # Upload media files if present
+ 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(f"Failed to upload file {media_path}: {e}")
except Exception as e:
logger.error(f"Error sending Slack message: {e}")
From e1854c4373cd7b944c18452bb0c852ee214c032d Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 11:13:10 +0000
Subject: [PATCH 205/506] feat: make Telegram reply-to-message behavior
configurable, default false
---
nanobot/channels/telegram.py | 14 +++++++-------
nanobot/config/schema.py | 1 +
2 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index d29fdfd..6cd98e7 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -224,14 +224,14 @@ class TelegramChannel(BaseChannel):
logger.error("Invalid chat_id: {}", msg.chat_id)
return
- # Build reply parameters (Will reply to the message if it exists)
- reply_to_message_id = msg.metadata.get("message_id")
reply_params = None
- if reply_to_message_id:
- reply_params = ReplyParameters(
- message_id=reply_to_message_id,
- allow_sending_without_reply=True
- )
+ 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 []):
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 570f322..966d11d 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -28,6 +28,7 @@ class TelegramConfig(Base):
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):
From 8db91f59e26cd0ca83fc5eb01dd346a2410d98f9 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 11:18:57 +0000
Subject: [PATCH 206/506] style: remove trailing space
---
README.md | 2 +-
nanobot/agent/loop.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index b37f8bd..68ad5a9 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
-📏 Real-time line count: **3,793 lines** (run `bash core_agent_lines.sh` to verify anytime)
+📏 Real-time line count: **3,827 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 14a8be6..3016d92 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -200,7 +200,7 @@ class AgentLoop:
if response.has_tool_calls:
if on_progress:
clean = self._strip_think(response.content)
- if clean:
+ if clean:
await on_progress(clean)
await on_progress(self._tool_hint(response.tool_calls))
From 5cc019bf1a53df1fd2ff1e50cd40b4e358804a4f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 11:27:21 +0000
Subject: [PATCH 207/506] style: trim verbose comments in _sanitize_messages
---
nanobot/providers/litellm_provider.py | 12 ++----------
1 file changed, 2 insertions(+), 10 deletions(-)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 4fe44f7..edeb5c6 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -12,9 +12,7 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.registry import find_by_model, find_gateway
-# Keys that are part of the OpenAI chat-completion message schema.
-# Anything else (e.g. reasoning_content, timestamp) is stripped before sending
-# to avoid "Unrecognized chat message" errors from strict providers like StepFun.
+# Standard OpenAI chat-completion message keys; extras (e.g. reasoning_content) are stripped for strict providers.
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"})
@@ -155,13 +153,7 @@ class LiteLLMProvider(LLMProvider):
@staticmethod
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
- """Strip non-standard keys from messages for strict providers.
-
- Some providers (e.g. StepFun via OpenRouter) reject messages that
- contain extra keys like ``reasoning_content``. This method keeps
- only the keys defined in the OpenAI chat-completion schema and
- ensures every assistant message has a ``content`` key.
- """
+ """Strip non-standard keys and ensure assistant messages have a content key."""
sanitized = []
for msg in messages:
clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS}
From 755e42412717c0c4372b79c4d53f0dcb050351f7 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Fri, 20 Feb 2026 12:38:43 +0100
Subject: [PATCH 208/506] fix(loop): serialize /new consolidation and track
task refs
---
nanobot/agent/loop.py | 251 ++++++++++++++++++++-----------
tests/test_consolidate_offset.py | 246 ++++++++++++++++++++++++++++++
2 files changed, 407 insertions(+), 90 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 3016d92..7806fb8 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -21,7 +21,6 @@ from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.cron import CronTool
-from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager
from nanobot.session.manager import Session, SessionManager
@@ -57,6 +56,7 @@ class AgentLoop:
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
+
self.bus = bus
self.provider = provider
self.workspace = workspace
@@ -84,14 +84,16 @@ class AgentLoop:
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
-
+
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._consolidating: set[str] = set() # Session keys with consolidation in progress
+ self._consolidation_tasks: set[asyncio.Task] = set() # Keep strong refs for in-flight tasks
+ self._consolidation_locks: dict[str, asyncio.Lock] = {}
self._register_default_tools()
-
+
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
# File tools (workspace for relative paths, restrict if configured)
@@ -100,36 +102,39 @@ class AgentLoop:
self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
-
+
# Shell tool
- self.tools.register(ExecTool(
- working_dir=str(self.workspace),
- timeout=self.exec_config.timeout,
- restrict_to_workspace=self.restrict_to_workspace,
- ))
-
+ self.tools.register(
+ ExecTool(
+ working_dir=str(self.workspace),
+ timeout=self.exec_config.timeout,
+ restrict_to_workspace=self.restrict_to_workspace,
+ )
+ )
+
# Web tools
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
-
+
# Message tool
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
self.tools.register(message_tool)
-
+
# Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool)
-
+
# Cron tool (for scheduling)
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
-
+
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or not self._mcp_servers:
return
self._mcp_connected = True
from nanobot.agent.tools.mcp import connect_mcp_servers
+
self._mcp_stack = AsyncExitStack()
await self._mcp_stack.__aenter__()
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
@@ -158,11 +163,13 @@ class AgentLoop:
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint, e.g. '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(
@@ -210,13 +217,15 @@ class AgentLoop:
"type": "function",
"function": {
"name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False)
- }
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False),
+ },
}
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
- messages, response.content, tool_call_dicts,
+ messages,
+ response.content,
+ tool_call_dicts,
reasoning_content=response.reasoning_content,
)
@@ -234,9 +243,13 @@ class AgentLoop:
# Give them one retry; don't forward the text to avoid duplicates.
if not tools_used and not text_only_retried and final_content:
text_only_retried = True
- logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80])
+ logger.debug(
+ "Interim text response (no tools used yet), retrying: {}",
+ final_content[:80],
+ )
messages = self.context.add_assistant_message(
- messages, response.content,
+ messages,
+ response.content,
reasoning_content=response.reasoning_content,
)
final_content = None
@@ -253,24 +266,23 @@ class AgentLoop:
while self._running:
try:
- msg = await asyncio.wait_for(
- self.bus.consume_inbound(),
- timeout=1.0
- )
+ msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
try:
response = await self._process_message(msg)
if response:
await self.bus.publish_outbound(response)
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)}"
- ))
+ 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:
"""Close MCP connections."""
if self._mcp_stack:
@@ -284,7 +296,15 @@ class AgentLoop:
"""Stop the agent loop."""
self._running = False
logger.info("Agent loop stopping")
-
+
+ def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock:
+ """Return a per-session lock for memory consolidation writers."""
+ lock = self._consolidation_locks.get(session_key)
+ if lock is None:
+ lock = asyncio.Lock()
+ self._consolidation_locks[session_key] = lock
+ return lock
+
async def _process_message(
self,
msg: InboundMessage,
@@ -293,56 +313,75 @@ class AgentLoop:
) -> OutboundMessage | None:
"""
Process a single inbound message.
-
+
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
on_progress: Optional callback for intermediate output (defaults to bus publish).
-
+
Returns:
The response message, or None if no response needed.
"""
# System messages route back via chat_id ("channel:chat_id")
if msg.channel == "system":
return await self._process_system_message(msg)
-
+
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)
-
+
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
- # Capture messages before clearing (avoid race condition with background task)
messages_to_archive = session.messages.copy()
+ lock = self._get_consolidation_lock(session.key)
+
+ try:
+ async with lock:
+ temp_session = Session(key=session.key)
+ temp_session.messages = messages_to_archive
+ await self._consolidate_memory(temp_session, archive_all=True)
+ except Exception as e:
+ logger.error("/new archival failed for {}: {}", session.key, e)
+ return OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="Could not start a new session because memory archival failed. Please try again.",
+ )
+
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session.key)
-
- async def _consolidate_and_cleanup():
- temp_session = Session(key=session.key)
- temp_session.messages = messages_to_archive
- await self._consolidate_memory(temp_session, archive_all=True)
-
- asyncio.create_task(_consolidate_and_cleanup())
- return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
- content="New session started. Memory consolidation in progress.")
+ return OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="New session started. Memory consolidation in progress.",
+ )
if cmd == "/help":
- return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
- content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
-
+ return OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands",
+ )
+
if len(session.messages) > 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:
- await self._consolidate_memory(session)
+ async with lock:
+ await self._consolidate_memory(session)
finally:
self._consolidating.discard(session.key)
+ task = asyncio.current_task()
+ if task is not None:
+ self._consolidation_tasks.discard(task)
- asyncio.create_task(_consolidate_and_unlock())
+ task = asyncio.create_task(_consolidate_and_unlock())
+ self._consolidation_tasks.add(task)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
initial_messages = self.context.build_messages(
@@ -354,42 +393,49 @@ class AgentLoop:
)
async def _bus_progress(content: str) -> None:
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id, content=content,
- metadata=msg.metadata or {},
- ))
+ await self.bus.publish_outbound(
+ OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=content,
+ metadata=msg.metadata or {},
+ )
+ )
final_content, tools_used = await self._run_agent_loop(
- initial_messages, on_progress=on_progress or _bus_progress,
+ initial_messages,
+ on_progress=on_progress or _bus_progress,
)
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)
-
+
session.add_message("user", msg.content)
- session.add_message("assistant", final_content,
- tools_used=tools_used if tools_used else None)
+ session.add_message(
+ "assistant", final_content, tools_used=tools_used if tools_used else None
+ )
self.sessions.save(session)
-
+
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
- metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
+ metadata=msg.metadata
+ or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
-
+
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
"""
Process a system message (e.g., subagent announce).
-
+
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
logger.info("Processing system message from {}", msg.sender_id)
-
+
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg.chat_id:
parts = msg.chat_id.split(":", 1)
@@ -399,7 +445,7 @@ class AgentLoop:
# Fallback
origin_channel = "cli"
origin_chat_id = msg.chat_id
-
+
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id"))
@@ -413,17 +459,15 @@ class AgentLoop:
if final_content is None:
final_content = "Background task completed."
-
+
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self.sessions.save(session)
-
+
return OutboundMessage(
- channel=origin_channel,
- chat_id=origin_chat_id,
- content=final_content
+ channel=origin_channel, chat_id=origin_chat_id, content=final_content
)
-
+
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
"""Consolidate old messages into MEMORY.md + HISTORY.md.
@@ -431,34 +475,54 @@ class AgentLoop:
archive_all: If True, clear all messages and reset session (for /new command).
If False, only write to files without modifying session.
"""
- memory = MemoryStore(self.workspace)
+ memory = self.context.memory
if archive_all:
old_messages = session.messages
keep_count = 0
- logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages))
+ logger.info(
+ "Memory consolidation (archive_all): {} total messages archived",
+ len(session.messages),
+ )
else:
keep_count = self.memory_window // 2
if len(session.messages) <= keep_count:
- logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count)
+ logger.debug(
+ "Session {}: No consolidation needed (messages={}, keep={})",
+ session.key,
+ len(session.messages),
+ keep_count,
+ )
return
messages_to_process = len(session.messages) - session.last_consolidated
if messages_to_process <= 0:
- logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages))
+ logger.debug(
+ "Session {}: No new messages to consolidate (last_consolidated={}, total={})",
+ session.key,
+ session.last_consolidated,
+ len(session.messages),
+ )
return
- old_messages = session.messages[session.last_consolidated:-keep_count]
+ old_messages = session.messages[session.last_consolidated : -keep_count]
if not old_messages:
return
- logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count)
+ logger.info(
+ "Memory consolidation started: {} total, {} new to consolidate, {} keep",
+ len(session.messages),
+ 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']}")
+ lines.append(
+ f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}"
+ )
conversation = "\n".join(lines)
current_memory = memory.read_long_term()
@@ -487,7 +551,10 @@ Respond with ONLY valid JSON, no markdown fences."""
try:
response = await self.provider.chat(
messages=[
- {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
+ {
+ "role": "system",
+ "content": "You are a memory consolidation agent. Respond only with valid JSON.",
+ },
{"role": "user", "content": prompt},
],
model=self.model,
@@ -500,7 +567,10 @@ Respond with ONLY valid JSON, no markdown fences."""
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
result = json_repair.loads(text)
if not isinstance(result, dict):
- logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200])
+ logger.warning(
+ "Memory consolidation: unexpected response type, skipping. Response: {}",
+ text[:200],
+ )
return
if entry := result.get("history_entry"):
@@ -519,7 +589,11 @@ Respond with ONLY valid JSON, no markdown fences."""
session.last_consolidated = 0
else:
session.last_consolidated = len(session.messages) - keep_count
- logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
+ logger.info(
+ "Memory consolidation done: {} messages, last_consolidated={}",
+ len(session.messages),
+ session.last_consolidated,
+ )
except Exception as e:
logger.error("Memory consolidation failed: {}", e)
@@ -533,24 +607,21 @@ Respond with ONLY valid JSON, no markdown fences."""
) -> str:
"""
Process a message directly (for CLI or cron usage).
-
+
Args:
content: The message content.
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
on_progress: Optional callback for intermediate output.
-
+
Returns:
The agent's response.
"""
await self._connect_mcp()
- msg = InboundMessage(
- channel=channel,
- sender_id="user",
- chat_id=chat_id,
- content=content
+ msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
+
+ response = await self._process_message(
+ msg, session_key=session_key, on_progress=on_progress
)
-
- response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""
diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py
index e204733..6162fa0 100644
--- a/tests/test_consolidate_offset.py
+++ b/tests/test_consolidate_offset.py
@@ -1,5 +1,8 @@
"""Test session management with cache-friendly message handling."""
+import asyncio
+from unittest.mock import AsyncMock, MagicMock
+
import pytest
from pathlib import Path
from nanobot.session.manager import Session, SessionManager
@@ -475,3 +478,246 @@ class TestEmptyAndBoundarySessions:
expected_count = 60 - KEEP_COUNT - 10
assert len(old_messages) == expected_count
assert_messages_content(old_messages, 10, 34)
+
+
+class TestConsolidationDeduplicationGuard:
+ """Test that consolidation tasks are deduplicated and serialized."""
+
+ @pytest.mark.asyncio
+ async def test_consolidation_guard_prevents_duplicate_tasks(self, tmp_path: Path) -> None:
+ """Concurrent messages above memory_window spawn only one consolidation task."""
+ from nanobot.agent.loop import AgentLoop
+ from nanobot.bus.events import InboundMessage
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ loop = AgentLoop(
+ bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
+ )
+
+ loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+
+ session = loop.sessions.get_or_create("cli:test")
+ for i in range(15):
+ session.add_message("user", f"msg{i}")
+ session.add_message("assistant", f"resp{i}")
+ loop.sessions.save(session)
+
+ consolidation_calls = 0
+
+ async def _fake_consolidate(_session, archive_all: bool = False) -> None:
+ nonlocal consolidation_calls
+ consolidation_calls += 1
+ await asyncio.sleep(0.05)
+
+ loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
+
+ msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
+ await loop._process_message(msg)
+ await loop._process_message(msg)
+ await asyncio.sleep(0.1)
+
+ assert consolidation_calls == 1, (
+ f"Expected exactly 1 consolidation, got {consolidation_calls}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_new_command_guard_prevents_concurrent_consolidation(
+ self, tmp_path: Path
+ ) -> None:
+ """/new command does not run consolidation concurrently with in-flight consolidation."""
+ from nanobot.agent.loop import AgentLoop
+ from nanobot.bus.events import InboundMessage
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ loop = AgentLoop(
+ bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
+ )
+
+ loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+
+ session = loop.sessions.get_or_create("cli:test")
+ for i in range(15):
+ session.add_message("user", f"msg{i}")
+ session.add_message("assistant", f"resp{i}")
+ loop.sessions.save(session)
+
+ consolidation_calls = 0
+ active = 0
+ max_active = 0
+
+ async def _fake_consolidate(_session, archive_all: bool = False) -> None:
+ nonlocal consolidation_calls, active, max_active
+ consolidation_calls += 1
+ active += 1
+ max_active = max(max_active, active)
+ await asyncio.sleep(0.05)
+ active -= 1
+
+ loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
+
+ msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
+ await loop._process_message(msg)
+
+ new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
+ await loop._process_message(new_msg)
+ await asyncio.sleep(0.1)
+
+ assert consolidation_calls == 2, (
+ f"Expected normal + /new consolidations, got {consolidation_calls}"
+ )
+ assert max_active == 1, (
+ f"Expected serialized consolidation, observed concurrency={max_active}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_consolidation_tasks_are_referenced(self, tmp_path: Path) -> None:
+ """create_task results are tracked in _consolidation_tasks while in flight."""
+ from nanobot.agent.loop import AgentLoop
+ from nanobot.bus.events import InboundMessage
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ loop = AgentLoop(
+ bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
+ )
+
+ loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+
+ session = loop.sessions.get_or_create("cli:test")
+ for i in range(15):
+ session.add_message("user", f"msg{i}")
+ session.add_message("assistant", f"resp{i}")
+ loop.sessions.save(session)
+
+ started = asyncio.Event()
+
+ async def _slow_consolidate(_session, archive_all: bool = False) -> None:
+ started.set()
+ await asyncio.sleep(0.1)
+
+ loop._consolidate_memory = _slow_consolidate # type: ignore[method-assign]
+
+ msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
+ await loop._process_message(msg)
+
+ await started.wait()
+ assert len(loop._consolidation_tasks) == 1, "Task must be referenced while in-flight"
+
+ await asyncio.sleep(0.15)
+ assert len(loop._consolidation_tasks) == 0, (
+ "Task reference must be removed after completion"
+ )
+
+ @pytest.mark.asyncio
+ async def test_new_waits_for_inflight_consolidation_and_preserves_messages(
+ self, tmp_path: Path
+ ) -> None:
+ """/new waits for in-flight consolidation and archives before clear."""
+ from nanobot.agent.loop import AgentLoop
+ from nanobot.bus.events import InboundMessage
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ loop = AgentLoop(
+ bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
+ )
+
+ loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+
+ session = loop.sessions.get_or_create("cli:test")
+ for i in range(15):
+ session.add_message("user", f"msg{i}")
+ session.add_message("assistant", f"resp{i}")
+ loop.sessions.save(session)
+
+ started = asyncio.Event()
+ release = asyncio.Event()
+ archived_count = 0
+
+ async def _fake_consolidate(sess, archive_all: bool = False) -> None:
+ nonlocal archived_count
+ if archive_all:
+ archived_count = len(sess.messages)
+ return
+ started.set()
+ await release.wait()
+
+ loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
+
+ msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
+ await loop._process_message(msg)
+ await started.wait()
+
+ new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
+ pending_new = asyncio.create_task(loop._process_message(new_msg))
+
+ await asyncio.sleep(0.02)
+ assert not pending_new.done(), "/new should wait while consolidation is in-flight"
+
+ release.set()
+ response = await pending_new
+ assert response is not None
+ assert "new session started" in response.content.lower()
+ assert archived_count > 0, "Expected /new archival to process a non-empty snapshot"
+
+ session_after = loop.sessions.get_or_create("cli:test")
+ assert session_after.messages == [], "Session should be cleared after successful archival"
+
+ @pytest.mark.asyncio
+ async def test_new_does_not_clear_session_when_archive_fails(self, tmp_path: Path) -> None:
+ """/new keeps session data if archive step fails."""
+ from nanobot.agent.loop import AgentLoop
+ from nanobot.bus.events import InboundMessage
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ loop = AgentLoop(
+ bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
+ )
+
+ loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+
+ session = loop.sessions.get_or_create("cli:test")
+ for i in range(5):
+ session.add_message("user", f"msg{i}")
+ session.add_message("assistant", f"resp{i}")
+ loop.sessions.save(session)
+ before_count = len(session.messages)
+
+ async def _failing_consolidate(_session, archive_all: bool = False) -> None:
+ if archive_all:
+ raise RuntimeError("forced archive failure")
+
+ loop._consolidate_memory = _failing_consolidate # type: ignore[method-assign]
+
+ new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
+ response = await loop._process_message(new_msg)
+
+ assert response is not None
+ assert "failed" in response.content.lower()
+ session_after = loop.sessions.get_or_create("cli:test")
+ assert len(session_after.messages) == before_count, (
+ "Session must remain intact when /new archival fails"
+ )
From 5f9eca466484e52ce535d4f20f4d0b87581da5db Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Fri, 20 Feb 2026 12:46:11 +0100
Subject: [PATCH 209/506] style(loop): remove formatting-only changes from
upstream PR 881
---
nanobot/agent/loop.py | 220 ++++++++++++++++--------------------------
1 file changed, 85 insertions(+), 135 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 7806fb8..481b72e 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -56,7 +56,6 @@ class AgentLoop:
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
-
self.bus = bus
self.provider = provider
self.workspace = workspace
@@ -84,16 +83,16 @@ class AgentLoop:
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
-
+
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._consolidating: set[str] = set() # Session keys with consolidation in progress
- self._consolidation_tasks: set[asyncio.Task] = set() # Keep strong refs for in-flight tasks
+ 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 _register_default_tools(self) -> None:
"""Register the default set of tools."""
# File tools (workspace for relative paths, restrict if configured)
@@ -102,39 +101,36 @@ class AgentLoop:
self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
-
+
# Shell tool
- self.tools.register(
- ExecTool(
- working_dir=str(self.workspace),
- timeout=self.exec_config.timeout,
- restrict_to_workspace=self.restrict_to_workspace,
- )
- )
-
+ self.tools.register(ExecTool(
+ working_dir=str(self.workspace),
+ timeout=self.exec_config.timeout,
+ restrict_to_workspace=self.restrict_to_workspace,
+ ))
+
# Web tools
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
-
+
# Message tool
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
self.tools.register(message_tool)
-
+
# Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool)
-
+
# Cron tool (for scheduling)
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
-
+
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or not self._mcp_servers:
return
self._mcp_connected = True
from nanobot.agent.tools.mcp import connect_mcp_servers
-
self._mcp_stack = AsyncExitStack()
await self._mcp_stack.__aenter__()
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
@@ -163,13 +159,11 @@ class AgentLoop:
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint, e.g. '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(
@@ -217,15 +211,13 @@ class AgentLoop:
"type": "function",
"function": {
"name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False),
- },
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False)
+ }
}
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
- messages,
- response.content,
- tool_call_dicts,
+ messages, response.content, tool_call_dicts,
reasoning_content=response.reasoning_content,
)
@@ -243,13 +235,9 @@ class AgentLoop:
# Give them one retry; don't forward the text to avoid duplicates.
if not tools_used and not text_only_retried and final_content:
text_only_retried = True
- logger.debug(
- "Interim text response (no tools used yet), retrying: {}",
- final_content[:80],
- )
+ logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80])
messages = self.context.add_assistant_message(
- messages,
- response.content,
+ messages, response.content,
reasoning_content=response.reasoning_content,
)
final_content = None
@@ -266,23 +254,24 @@ class AgentLoop:
while self._running:
try:
- msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
+ msg = await asyncio.wait_for(
+ self.bus.consume_inbound(),
+ timeout=1.0
+ )
try:
response = await self._process_message(msg)
if response:
await self.bus.publish_outbound(response)
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)}",
- )
- )
+ 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:
"""Close MCP connections."""
if self._mcp_stack:
@@ -298,13 +287,12 @@ class AgentLoop:
logger.info("Agent loop stopping")
def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock:
- """Return a per-session lock for memory consolidation writers."""
lock = self._consolidation_locks.get(session_key)
if lock is None:
lock = asyncio.Lock()
self._consolidation_locks[session_key] = lock
return lock
-
+
async def _process_message(
self,
msg: InboundMessage,
@@ -313,25 +301,25 @@ class AgentLoop:
) -> OutboundMessage | None:
"""
Process a single inbound message.
-
+
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
on_progress: Optional callback for intermediate output (defaults to bus publish).
-
+
Returns:
The response message, or None if no response needed.
"""
# System messages route back via chat_id ("channel:chat_id")
if msg.channel == "system":
return await self._process_system_message(msg)
-
+
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)
-
+
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
@@ -348,24 +336,18 @@ class AgentLoop:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
- content="Could not start a new session because memory archival failed. Please try again.",
+ content="Could not start a new session because memory archival failed. Please try again."
)
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session.key)
- return OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="New session started. Memory consolidation in progress.",
- )
+ return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
+ content="New session started. Memory consolidation in progress.")
if cmd == "/help":
- return OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands",
- )
-
+ return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
+ content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
+
if len(session.messages) > self.memory_window and session.key not in self._consolidating:
self._consolidating.add(session.key)
lock = self._get_consolidation_lock(session.key)
@@ -376,12 +358,12 @@ class AgentLoop:
await self._consolidate_memory(session)
finally:
self._consolidating.discard(session.key)
- task = asyncio.current_task()
- if task is not None:
- self._consolidation_tasks.discard(task)
+ _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)
+ _task = asyncio.create_task(_consolidate_and_unlock())
+ self._consolidation_tasks.add(_task)
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
initial_messages = self.context.build_messages(
@@ -393,49 +375,42 @@ class AgentLoop:
)
async def _bus_progress(content: str) -> None:
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content=content,
- metadata=msg.metadata or {},
- )
- )
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel, chat_id=msg.chat_id, content=content,
+ metadata=msg.metadata or {},
+ ))
final_content, tools_used = await self._run_agent_loop(
- initial_messages,
- on_progress=on_progress or _bus_progress,
+ initial_messages, on_progress=on_progress or _bus_progress,
)
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)
-
+
session.add_message("user", msg.content)
- session.add_message(
- "assistant", final_content, tools_used=tools_used if tools_used else None
- )
+ session.add_message("assistant", final_content,
+ tools_used=tools_used if tools_used else None)
self.sessions.save(session)
-
+
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
- metadata=msg.metadata
- or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
+ metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
-
+
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
"""
Process a system message (e.g., subagent announce).
-
+
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
logger.info("Processing system message from {}", msg.sender_id)
-
+
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg.chat_id:
parts = msg.chat_id.split(":", 1)
@@ -445,7 +420,7 @@ class AgentLoop:
# Fallback
origin_channel = "cli"
origin_chat_id = msg.chat_id
-
+
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id"))
@@ -459,15 +434,17 @@ class AgentLoop:
if final_content is None:
final_content = "Background task completed."
-
+
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self.sessions.save(session)
-
+
return OutboundMessage(
- channel=origin_channel, chat_id=origin_chat_id, content=final_content
+ channel=origin_channel,
+ chat_id=origin_chat_id,
+ content=final_content
)
-
+
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
"""Consolidate old messages into MEMORY.md + HISTORY.md.
@@ -480,49 +457,29 @@ class AgentLoop:
if archive_all:
old_messages = session.messages
keep_count = 0
- logger.info(
- "Memory consolidation (archive_all): {} total messages archived",
- len(session.messages),
- )
+ logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages))
else:
keep_count = self.memory_window // 2
if len(session.messages) <= keep_count:
- logger.debug(
- "Session {}: No consolidation needed (messages={}, keep={})",
- session.key,
- len(session.messages),
- keep_count,
- )
+ logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count)
return
messages_to_process = len(session.messages) - session.last_consolidated
if messages_to_process <= 0:
- logger.debug(
- "Session {}: No new messages to consolidate (last_consolidated={}, total={})",
- session.key,
- session.last_consolidated,
- len(session.messages),
- )
+ logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages))
return
- old_messages = session.messages[session.last_consolidated : -keep_count]
+ old_messages = session.messages[session.last_consolidated:-keep_count]
if not old_messages:
return
- logger.info(
- "Memory consolidation started: {} total, {} new to consolidate, {} keep",
- len(session.messages),
- len(old_messages),
- keep_count,
- )
+ logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), 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']}"
- )
+ lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
conversation = "\n".join(lines)
current_memory = memory.read_long_term()
@@ -551,10 +508,7 @@ Respond with ONLY valid JSON, no markdown fences."""
try:
response = await self.provider.chat(
messages=[
- {
- "role": "system",
- "content": "You are a memory consolidation agent. Respond only with valid JSON.",
- },
+ {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
{"role": "user", "content": prompt},
],
model=self.model,
@@ -567,10 +521,7 @@ Respond with ONLY valid JSON, no markdown fences."""
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
result = json_repair.loads(text)
if not isinstance(result, dict):
- logger.warning(
- "Memory consolidation: unexpected response type, skipping. Response: {}",
- text[:200],
- )
+ logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200])
return
if entry := result.get("history_entry"):
@@ -589,11 +540,7 @@ Respond with ONLY valid JSON, no markdown fences."""
session.last_consolidated = 0
else:
session.last_consolidated = len(session.messages) - keep_count
- logger.info(
- "Memory consolidation done: {} messages, last_consolidated={}",
- len(session.messages),
- session.last_consolidated,
- )
+ logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
except Exception as e:
logger.error("Memory consolidation failed: {}", e)
@@ -607,21 +554,24 @@ Respond with ONLY valid JSON, no markdown fences."""
) -> str:
"""
Process a message directly (for CLI or cron usage).
-
+
Args:
content: The message content.
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
on_progress: Optional callback for intermediate output.
-
+
Returns:
The agent's response.
"""
await self._connect_mcp()
- msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
-
- response = await self._process_message(
- msg, session_key=session_key, on_progress=on_progress
+ msg = InboundMessage(
+ channel=channel,
+ sender_id="user",
+ chat_id=chat_id,
+ content=content
)
+
+ response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""
From 9ada8e68547bae6daeb8e6e43b7c1babdb519724 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Fri, 20 Feb 2026 12:48:54 +0100
Subject: [PATCH 210/506] fix(loop): require successful archival before /new
clear
---
nanobot/agent/loop.py | 230 +++++++++++++++++++------------
tests/test_consolidate_offset.py | 12 +-
2 files changed, 151 insertions(+), 91 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 481b72e..4ff01ea 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -56,6 +56,7 @@ class AgentLoop:
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
+
self.bus = bus
self.provider = provider
self.workspace = workspace
@@ -83,7 +84,7 @@ class AgentLoop:
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
-
+
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
@@ -92,7 +93,7 @@ class AgentLoop:
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 _register_default_tools(self) -> None:
"""Register the default set of tools."""
# File tools (workspace for relative paths, restrict if configured)
@@ -101,36 +102,39 @@ class AgentLoop:
self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
-
+
# Shell tool
- self.tools.register(ExecTool(
- working_dir=str(self.workspace),
- timeout=self.exec_config.timeout,
- restrict_to_workspace=self.restrict_to_workspace,
- ))
-
+ self.tools.register(
+ ExecTool(
+ working_dir=str(self.workspace),
+ timeout=self.exec_config.timeout,
+ restrict_to_workspace=self.restrict_to_workspace,
+ )
+ )
+
# Web tools
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
-
+
# Message tool
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
self.tools.register(message_tool)
-
+
# Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool)
-
+
# Cron tool (for scheduling)
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
-
+
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or not self._mcp_servers:
return
self._mcp_connected = True
from nanobot.agent.tools.mcp import connect_mcp_servers
+
self._mcp_stack = AsyncExitStack()
await self._mcp_stack.__aenter__()
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
@@ -159,11 +163,13 @@ class AgentLoop:
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint, e.g. '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(
@@ -211,13 +217,15 @@ class AgentLoop:
"type": "function",
"function": {
"name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False)
- }
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False),
+ },
}
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
- messages, response.content, tool_call_dicts,
+ messages,
+ response.content,
+ tool_call_dicts,
reasoning_content=response.reasoning_content,
)
@@ -235,9 +243,13 @@ class AgentLoop:
# Give them one retry; don't forward the text to avoid duplicates.
if not tools_used and not text_only_retried and final_content:
text_only_retried = True
- logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80])
+ logger.debug(
+ "Interim text response (no tools used yet), retrying: {}",
+ final_content[:80],
+ )
messages = self.context.add_assistant_message(
- messages, response.content,
+ messages,
+ response.content,
reasoning_content=response.reasoning_content,
)
final_content = None
@@ -254,24 +266,23 @@ class AgentLoop:
while self._running:
try:
- msg = await asyncio.wait_for(
- self.bus.consume_inbound(),
- timeout=1.0
- )
+ msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
try:
response = await self._process_message(msg)
if response:
await self.bus.publish_outbound(response)
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)}"
- ))
+ 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:
"""Close MCP connections."""
if self._mcp_stack:
@@ -292,7 +303,7 @@ class AgentLoop:
lock = asyncio.Lock()
self._consolidation_locks[session_key] = lock
return lock
-
+
async def _process_message(
self,
msg: InboundMessage,
@@ -301,25 +312,25 @@ class AgentLoop:
) -> OutboundMessage | None:
"""
Process a single inbound message.
-
+
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
on_progress: Optional callback for intermediate output (defaults to bus publish).
-
+
Returns:
The response message, or None if no response needed.
"""
# System messages route back via chat_id ("channel:chat_id")
if msg.channel == "system":
return await self._process_system_message(msg)
-
+
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)
-
+
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
@@ -330,24 +341,37 @@ class AgentLoop:
async with lock:
temp_session = Session(key=session.key)
temp_session.messages = messages_to_archive
- await self._consolidate_memory(temp_session, archive_all=True)
+ archived = await self._consolidate_memory(temp_session, archive_all=True)
except Exception as e:
logger.error("/new archival failed for {}: {}", session.key, e)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
- content="Could not start a new session because memory archival failed. Please try again."
+ content="Could not start a new session because memory archival failed. Please try again.",
+ )
+
+ if messages_to_archive and not archived:
+ return OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="Could not start a new session because memory archival failed. Please try again.",
)
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session.key)
- return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
- content="New session started. Memory consolidation in progress.")
+ return OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="New session started. Memory consolidation in progress.",
+ )
if cmd == "/help":
- return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
- content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
-
+ return OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands",
+ )
+
if len(session.messages) > self.memory_window and session.key not in self._consolidating:
self._consolidating.add(session.key)
lock = self._get_consolidation_lock(session.key)
@@ -375,42 +399,49 @@ class AgentLoop:
)
async def _bus_progress(content: str) -> None:
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id, content=content,
- metadata=msg.metadata or {},
- ))
+ await self.bus.publish_outbound(
+ OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=content,
+ metadata=msg.metadata or {},
+ )
+ )
final_content, tools_used = await self._run_agent_loop(
- initial_messages, on_progress=on_progress or _bus_progress,
+ initial_messages,
+ on_progress=on_progress or _bus_progress,
)
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)
-
+
session.add_message("user", msg.content)
- session.add_message("assistant", final_content,
- tools_used=tools_used if tools_used else None)
+ session.add_message(
+ "assistant", final_content, tools_used=tools_used if tools_used else None
+ )
self.sessions.save(session)
-
+
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
- metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
+ metadata=msg.metadata
+ or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
-
+
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
"""
Process a system message (e.g., subagent announce).
-
+
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
logger.info("Processing system message from {}", msg.sender_id)
-
+
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg.chat_id:
parts = msg.chat_id.split(":", 1)
@@ -420,7 +451,7 @@ class AgentLoop:
# Fallback
origin_channel = "cli"
origin_chat_id = msg.chat_id
-
+
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id"))
@@ -434,18 +465,16 @@ class AgentLoop:
if final_content is None:
final_content = "Background task completed."
-
+
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self.sessions.save(session)
-
+
return OutboundMessage(
- channel=origin_channel,
- chat_id=origin_chat_id,
- content=final_content
+ channel=origin_channel, chat_id=origin_chat_id, content=final_content
)
-
- async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
+
+ async def _consolidate_memory(self, session, archive_all: bool = False) -> bool:
"""Consolidate old messages into MEMORY.md + HISTORY.md.
Args:
@@ -457,29 +486,49 @@ class AgentLoop:
if archive_all:
old_messages = session.messages
keep_count = 0
- logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages))
+ logger.info(
+ "Memory consolidation (archive_all): {} total messages archived",
+ len(session.messages),
+ )
else:
keep_count = self.memory_window // 2
if len(session.messages) <= keep_count:
- logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count)
- return
+ logger.debug(
+ "Session {}: No consolidation needed (messages={}, keep={})",
+ session.key,
+ len(session.messages),
+ keep_count,
+ )
+ return True
messages_to_process = len(session.messages) - session.last_consolidated
if messages_to_process <= 0:
- logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages))
- return
+ logger.debug(
+ "Session {}: No new messages to consolidate (last_consolidated={}, total={})",
+ session.key,
+ session.last_consolidated,
+ len(session.messages),
+ )
+ return True
- old_messages = session.messages[session.last_consolidated:-keep_count]
+ old_messages = session.messages[session.last_consolidated : -keep_count]
if not old_messages:
- return
- logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count)
+ return True
+ logger.info(
+ "Memory consolidation started: {} total, {} new to consolidate, {} keep",
+ len(session.messages),
+ 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']}")
+ lines.append(
+ f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}"
+ )
conversation = "\n".join(lines)
current_memory = memory.read_long_term()
@@ -508,7 +557,10 @@ Respond with ONLY valid JSON, no markdown fences."""
try:
response = await self.provider.chat(
messages=[
- {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
+ {
+ "role": "system",
+ "content": "You are a memory consolidation agent. Respond only with valid JSON.",
+ },
{"role": "user", "content": prompt},
],
model=self.model,
@@ -516,13 +568,16 @@ Respond with ONLY valid JSON, no markdown fences."""
text = (response.content or "").strip()
if not text:
logger.warning("Memory consolidation: LLM returned empty response, skipping")
- return
+ return False
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
result = json_repair.loads(text)
if not isinstance(result, dict):
- logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200])
- return
+ logger.warning(
+ "Memory consolidation: unexpected response type, skipping. Response: {}",
+ text[:200],
+ )
+ return False
if entry := result.get("history_entry"):
# Defensive: ensure entry is a string (LLM may return dict)
@@ -540,9 +595,15 @@ Respond with ONLY valid JSON, no markdown fences."""
session.last_consolidated = 0
else:
session.last_consolidated = len(session.messages) - keep_count
- logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
+ logger.info(
+ "Memory consolidation done: {} messages, last_consolidated={}",
+ len(session.messages),
+ session.last_consolidated,
+ )
+ return True
except Exception as e:
logger.error("Memory consolidation failed: {}", e)
+ return False
async def process_direct(
self,
@@ -554,24 +615,21 @@ Respond with ONLY valid JSON, no markdown fences."""
) -> str:
"""
Process a message directly (for CLI or cron usage).
-
+
Args:
content: The message content.
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
on_progress: Optional callback for intermediate output.
-
+
Returns:
The agent's response.
"""
await self._connect_mcp()
- msg = InboundMessage(
- channel=channel,
- sender_id="user",
- chat_id=chat_id,
- content=content
+ msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
+
+ response = await self._process_message(
+ msg, session_key=session_key, on_progress=on_progress
)
-
- response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""
diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py
index 6162fa0..6be808d 100644
--- a/tests/test_consolidate_offset.py
+++ b/tests/test_consolidate_offset.py
@@ -652,13 +652,14 @@ class TestConsolidationDeduplicationGuard:
release = asyncio.Event()
archived_count = 0
- async def _fake_consolidate(sess, archive_all: bool = False) -> None:
+ async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
nonlocal archived_count
if archive_all:
archived_count = len(sess.messages)
- return
+ return True
started.set()
await release.wait()
+ return True
loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
@@ -683,7 +684,7 @@ class TestConsolidationDeduplicationGuard:
@pytest.mark.asyncio
async def test_new_does_not_clear_session_when_archive_fails(self, tmp_path: Path) -> None:
- """/new keeps session data if archive step fails."""
+ """/new must keep session data if archive step reports failure."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
@@ -706,9 +707,10 @@ class TestConsolidationDeduplicationGuard:
loop.sessions.save(session)
before_count = len(session.messages)
- async def _failing_consolidate(_session, archive_all: bool = False) -> None:
+ async def _failing_consolidate(sess, archive_all: bool = False) -> bool:
if archive_all:
- raise RuntimeError("forced archive failure")
+ return False
+ return True
loop._consolidate_memory = _failing_consolidate # type: ignore[method-assign]
From ddae3e9d5f73ab9d95aa866d9894190d0f2200de Mon Sep 17 00:00:00 2001
From: Kim <150593189+KimGLee@users.noreply.github.com>
Date: Thu, 19 Feb 2026 11:25:47 +0800
Subject: [PATCH 211/506] fix(agent): avoid duplicate final send when message
tool already replied
---
nanobot/agent/loop.py | 120 +++++++++++++++++++--------------
nanobot/agent/tools/message.py | 43 +++++++-----
2 files changed, 97 insertions(+), 66 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 3016d92..926fad9 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -1,30 +1,36 @@
"""Agent loop: the core processing engine."""
-import asyncio
-from contextlib import AsyncExitStack
-import json
-import json_repair
-from pathlib import Path
-import re
-from typing import Any, Awaitable, Callable
+from __future__ import annotations
+import asyncio
+import json
+import re
+from contextlib import AsyncExitStack
+from pathlib import Path
+from typing import TYPE_CHECKING, Awaitable, Callable
+
+import json_repair
from loguru import logger
+from nanobot.agent.context import ContextBuilder
+from nanobot.agent.memory import MemoryStore
+from nanobot.agent.subagent import SubagentManager
+from nanobot.agent.tools.cron import CronTool
+from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
+from nanobot.agent.tools.message import MessageTool
+from nanobot.agent.tools.registry import ToolRegistry
+from nanobot.agent.tools.shell import ExecTool
+from nanobot.agent.tools.spawn import SpawnTool
+from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
-from nanobot.agent.context import ContextBuilder
-from nanobot.agent.tools.registry import ToolRegistry
-from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
-from nanobot.agent.tools.shell import ExecTool
-from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
-from nanobot.agent.tools.message import MessageTool
-from nanobot.agent.tools.spawn import SpawnTool
-from nanobot.agent.tools.cron import CronTool
-from nanobot.agent.memory import MemoryStore
-from nanobot.agent.subagent import SubagentManager
from nanobot.session.manager import Session, SessionManager
+if TYPE_CHECKING:
+ from nanobot.config.schema import ExecToolConfig
+ from nanobot.cron.service import CronService
+
class AgentLoop:
"""
@@ -49,14 +55,13 @@ class AgentLoop:
max_tokens: int = 4096,
memory_window: int = 50,
brave_api_key: str | None = None,
- exec_config: "ExecToolConfig | None" = None,
- cron_service: "CronService | None" = None,
+ exec_config: ExecToolConfig | None = None,
+ cron_service: CronService | None = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
mcp_servers: dict | None = None,
):
from nanobot.config.schema import ExecToolConfig
- from nanobot.cron.service import CronService
self.bus = bus
self.provider = provider
self.workspace = workspace
@@ -84,14 +89,14 @@ class AgentLoop:
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
-
+
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._consolidating: set[str] = set() # Session keys with consolidation in progress
self._register_default_tools()
-
+
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
# File tools (workspace for relative paths, restrict if configured)
@@ -100,30 +105,30 @@ class AgentLoop:
self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
-
+
# Shell tool
self.tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
))
-
+
# Web tools
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
-
+
# Message tool
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
self.tools.register(message_tool)
-
+
# Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool)
-
+
# Cron tool (for scheduling)
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
-
+
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or not self._mcp_servers:
@@ -270,7 +275,7 @@ class AgentLoop:
))
except asyncio.TimeoutError:
continue
-
+
async def close_mcp(self) -> None:
"""Close MCP connections."""
if self._mcp_stack:
@@ -284,7 +289,7 @@ class AgentLoop:
"""Stop the agent loop."""
self._running = False
logger.info("Agent loop stopping")
-
+
async def _process_message(
self,
msg: InboundMessage,
@@ -293,25 +298,25 @@ class AgentLoop:
) -> OutboundMessage | None:
"""
Process a single inbound message.
-
+
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
on_progress: Optional callback for intermediate output (defaults to bus publish).
-
+
Returns:
The response message, or None if no response needed.
"""
# System messages route back via chat_id ("channel:chat_id")
if msg.channel == "system":
return await self._process_system_message(msg)
-
+
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)
-
+
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
@@ -332,7 +337,7 @@ class AgentLoop:
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
-
+
if len(session.messages) > self.memory_window and session.key not in self._consolidating:
self._consolidating.add(session.key)
@@ -345,6 +350,10 @@ class AgentLoop:
asyncio.create_task(_consolidate_and_unlock())
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
+ if message_tool := self.tools.get("message"):
+ if isinstance(message_tool, MessageTool):
+ message_tool.start_turn()
+
initial_messages = self.context.build_messages(
history=session.get_history(max_messages=self.memory_window),
current_message=msg.content,
@@ -365,31 +374,44 @@ class AgentLoop:
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)
-
+
session.add_message("user", msg.content)
session.add_message("assistant", final_content,
tools_used=tools_used if tools_used else None)
self.sessions.save(session)
-
+
+ suppress_final_reply = False
+ if message_tool := self.tools.get("message"):
+ if isinstance(message_tool, MessageTool):
+ sent_targets = set(message_tool.get_turn_sends())
+ suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets
+
+ if suppress_final_reply:
+ logger.info(
+ "Skipping final auto-reply because message tool already sent to "
+ f"{msg.channel}:{msg.chat_id} in this turn"
+ )
+ return None
+
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
-
+
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
"""
Process a system message (e.g., subagent announce).
-
+
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
logger.info("Processing system message from {}", msg.sender_id)
-
+
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg.chat_id:
parts = msg.chat_id.split(":", 1)
@@ -399,7 +421,7 @@ class AgentLoop:
# Fallback
origin_channel = "cli"
origin_chat_id = msg.chat_id
-
+
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id"))
@@ -413,17 +435,17 @@ class AgentLoop:
if final_content is None:
final_content = "Background task completed."
-
+
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self.sessions.save(session)
-
+
return OutboundMessage(
channel=origin_channel,
chat_id=origin_chat_id,
content=final_content
)
-
+
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
"""Consolidate old messages into MEMORY.md + HISTORY.md.
@@ -533,14 +555,14 @@ Respond with ONLY valid JSON, no markdown fences."""
) -> str:
"""
Process a message directly (for CLI or cron usage).
-
+
Args:
content: The message content.
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
on_progress: Optional callback for intermediate output.
-
+
Returns:
The agent's response.
"""
@@ -551,6 +573,6 @@ Respond with ONLY valid JSON, no markdown fences."""
chat_id=chat_id,
content=content
)
-
+
response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""
diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py
index 10947c4..593bc44 100644
--- a/nanobot/agent/tools/message.py
+++ b/nanobot/agent/tools/message.py
@@ -1,6 +1,6 @@
"""Message tool for sending messages to users."""
-from typing import Any, Callable, Awaitable
+from typing import Any, Awaitable, Callable
from nanobot.agent.tools.base import Tool
from nanobot.bus.events import OutboundMessage
@@ -8,37 +8,45 @@ from nanobot.bus.events import OutboundMessage
class MessageTool(Tool):
"""Tool to send messages to users on chat channels."""
-
+
def __init__(
- self,
+ self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
default_channel: str = "",
default_chat_id: str = "",
- default_message_id: str | None = None
+ 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._turn_sends: list[tuple[str, str]] = []
+
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._turn_sends.clear()
+
+ def get_turn_sends(self) -> list[tuple[str, str]]:
+ """Get (channel, chat_id) targets sent in the current turn."""
+ return list(self._turn_sends)
+
@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 {
@@ -64,11 +72,11 @@ class MessageTool(Tool):
},
"required": ["content"]
}
-
+
async def execute(
- self,
- content: str,
- channel: str | None = None,
+ self,
+ content: str,
+ channel: str | None = None,
chat_id: str | None = None,
message_id: str | None = None,
media: list[str] | None = None,
@@ -77,13 +85,13 @@ class MessageTool(Tool):
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,
@@ -93,9 +101,10 @@ class MessageTool(Tool):
"message_id": message_id,
}
)
-
+
try:
await self._send_callback(msg)
+ self._turn_sends.append((channel, chat_id))
media_info = f" with {len(media)} attachments" if media else ""
return f"Message sent to {channel}:{chat_id}{media_info}"
except Exception as e:
From 4eb07c44b982076f29331e2d41e1f6963cfc48db Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Fri, 20 Feb 2026 09:21:27 -0300
Subject: [PATCH 212/506] fix: preserve interim content as fallback when retry
produces empty response
Fixes regression from #825 where models that respond with final text
directly (no tools) had their answer discarded by the retry mechanism.
Closes #878
---
nanobot/agent/loop.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 3016d92..d817815 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -185,6 +185,7 @@ class AgentLoop:
final_content = None
tools_used: list[str] = []
text_only_retried = False
+ interim_content: str | None = None # Fallback if retry produces nothing
while iteration < self.max_iterations:
iteration += 1
@@ -231,9 +232,11 @@ class AgentLoop:
else:
final_content = self._strip_think(response.content)
# Some models send an interim text response before tool calls.
- # Give them one retry; don't forward the text to avoid duplicates.
+ # Give them one retry; save the content as fallback in case
+ # the retry produces nothing useful (e.g. model already answered).
if not tools_used and not text_only_retried and final_content:
text_only_retried = True
+ interim_content = final_content
logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80])
messages = self.context.add_assistant_message(
messages, response.content,
@@ -241,6 +244,9 @@ class AgentLoop:
)
final_content = None
continue
+ # Fall back to interim content if retry produced nothing
+ if not final_content and interim_content:
+ final_content = interim_content
break
return final_content, tools_used
From 44f44b305a60ba27adf4ed8bdfb577412b6d9176 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Fri, 20 Feb 2026 09:24:48 -0300
Subject: [PATCH 213/506] fix: move MCP connected flag after successful
connection to allow retry
The flag was set before the connection attempt, so if any MCP server
was temporarily unavailable, the flag stayed True and MCP tools were
permanently lost for the session.
Closes #889
---
nanobot/agent/loop.py | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 3016d92..9078318 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -128,11 +128,20 @@ class AgentLoop:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or not self._mcp_servers:
return
- self._mcp_connected = True
from nanobot.agent.tools.mcp import connect_mcp_servers
- self._mcp_stack = AsyncExitStack()
- await self._mcp_stack.__aenter__()
- await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
+ try:
+ self._mcp_stack = AsyncExitStack()
+ await self._mcp_stack.__aenter__()
+ await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
+ self._mcp_connected = True
+ 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
def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Update context for all tools that need routing info."""
From 8cc54b188d81504f39ea15cfd656b4b40af0eb8a Mon Sep 17 00:00:00 2001
From: Kim <150593189+KimGLee@users.noreply.github.com>
Date: Fri, 20 Feb 2026 20:25:46 +0800
Subject: [PATCH 214/506] style(logging): use loguru parameterized formatting
in suppression log
---
nanobot/agent/loop.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 926fad9..646b658 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -391,8 +391,9 @@ class AgentLoop:
if suppress_final_reply:
logger.info(
- "Skipping final auto-reply because message tool already sent to "
- f"{msg.channel}:{msg.chat_id} in this turn"
+ "Skipping final auto-reply because message tool already sent to {}:{} in this turn",
+ msg.channel,
+ msg.chat_id,
)
return None
From c1b5e8c8d29a2225a1f82a36a93da5a0b61d702f Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Fri, 20 Feb 2026 13:29:18 +0100
Subject: [PATCH 215/506] fix(loop): lock /new snapshot and prune stale
consolidation locks
---
nanobot/agent/loop.py | 14 ++++-
tests/test_consolidate_offset.py | 103 +++++++++++++++++++++++++++++++
2 files changed, 115 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 4ff01ea..b0bace5 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -304,6 +304,14 @@ class AgentLoop:
self._consolidation_locks[session_key] = lock
return lock
+ def _prune_consolidation_lock(self, session_key: str, lock: asyncio.Lock) -> None:
+ """Drop unused per-session lock entries to avoid unbounded growth."""
+ waiters = getattr(lock, "_waiters", None)
+ has_waiters = bool(waiters)
+ if lock.locked() or has_waiters:
+ return
+ self._consolidation_locks.pop(session_key, None)
+
async def _process_message(
self,
msg: InboundMessage,
@@ -334,11 +342,11 @@ class AgentLoop:
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
- messages_to_archive = session.messages.copy()
lock = self._get_consolidation_lock(session.key)
-
+ messages_to_archive: list[dict[str, Any]] = []
try:
async with lock:
+ messages_to_archive = session.messages[session.last_consolidated :].copy()
temp_session = Session(key=session.key)
temp_session.messages = messages_to_archive
archived = await self._consolidate_memory(temp_session, archive_all=True)
@@ -360,6 +368,7 @@ class AgentLoop:
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session.key)
+ self._prune_consolidation_lock(session.key, lock)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
@@ -382,6 +391,7 @@ class AgentLoop:
await self._consolidate_memory(session)
finally:
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)
diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py
index 6be808d..323519e 100644
--- a/tests/test_consolidate_offset.py
+++ b/tests/test_consolidate_offset.py
@@ -723,3 +723,106 @@ class TestConsolidationDeduplicationGuard:
assert len(session_after.messages) == before_count, (
"Session must remain intact when /new archival fails"
)
+
+ @pytest.mark.asyncio
+ async def test_new_archives_only_unconsolidated_messages_after_inflight_task(
+ self, tmp_path: Path
+ ) -> None:
+ """/new should archive only messages not yet consolidated by prior task."""
+ from nanobot.agent.loop import AgentLoop
+ from nanobot.bus.events import InboundMessage
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ loop = AgentLoop(
+ bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
+ )
+
+ loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+
+ session = loop.sessions.get_or_create("cli:test")
+ for i in range(15):
+ session.add_message("user", f"msg{i}")
+ session.add_message("assistant", f"resp{i}")
+ loop.sessions.save(session)
+
+ started = asyncio.Event()
+ release = asyncio.Event()
+ archived_count = -1
+
+ async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
+ nonlocal archived_count
+ if archive_all:
+ archived_count = len(sess.messages)
+ return True
+
+ started.set()
+ await release.wait()
+ sess.last_consolidated = len(sess.messages) - 3
+ return True
+
+ loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
+
+ msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
+ await loop._process_message(msg)
+ await started.wait()
+
+ new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
+ pending_new = asyncio.create_task(loop._process_message(new_msg))
+ await asyncio.sleep(0.02)
+ assert not pending_new.done()
+
+ release.set()
+ response = await pending_new
+
+ assert response is not None
+ assert "new session started" in response.content.lower()
+ assert archived_count == 3, (
+ f"Expected only unconsolidated tail to archive, got {archived_count}"
+ )
+
+ @pytest.mark.asyncio
+ async def test_new_cleans_up_consolidation_lock_for_invalidated_session(
+ self, tmp_path: Path
+ ) -> None:
+ """/new should remove lock entry for fully invalidated session key."""
+ from nanobot.agent.loop import AgentLoop
+ from nanobot.bus.events import InboundMessage
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ loop = AgentLoop(
+ bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
+ )
+
+ loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+
+ session = loop.sessions.get_or_create("cli:test")
+ for i in range(3):
+ session.add_message("user", f"msg{i}")
+ session.add_message("assistant", f"resp{i}")
+ loop.sessions.save(session)
+
+ # Ensure lock exists before /new.
+ _ = loop._get_consolidation_lock(session.key)
+ assert session.key in loop._consolidation_locks
+
+ async def _ok_consolidate(sess, archive_all: bool = False) -> bool:
+ return True
+
+ loop._consolidate_memory = _ok_consolidate # type: ignore[method-assign]
+
+ new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
+ response = await loop._process_message(new_msg)
+
+ assert response is not None
+ assert "new session started" in response.content.lower()
+ assert session.key not in loop._consolidation_locks
From df022febaf4782270588431c0ba7c6b84f4b29c9 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Fri, 20 Feb 2026 13:31:15 +0100
Subject: [PATCH 216/506] refactor(loop): drop redundant Any typing in /new
snapshot
---
nanobot/agent/loop.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index b0bace5..42ab351 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -6,7 +6,7 @@ import json
import json_repair
from pathlib import Path
import re
-from typing import Any, Awaitable, Callable
+from typing import Awaitable, Callable
from loguru import logger
@@ -343,7 +343,7 @@ class AgentLoop:
cmd = msg.content.strip().lower()
if cmd == "/new":
lock = self._get_consolidation_lock(session.key)
- messages_to_archive: list[dict[str, Any]] = []
+ messages_to_archive = []
try:
async with lock:
messages_to_archive = session.messages[session.last_consolidated :].copy()
From 45f33853cf952b8642c104a5ecaa263a473f4963 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Fri, 20 Feb 2026 09:37:42 -0300
Subject: [PATCH 217/506] fix: only apply interim fallback when no tools were
used
Addresses Codex review: if the model sent interim text then used tools,
the interim text should not be used as fallback for the final response.
---
nanobot/agent/loop.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index d817815..177302e 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -245,7 +245,8 @@ class AgentLoop:
final_content = None
continue
# Fall back to interim content if retry produced nothing
- if not final_content and interim_content:
+ # and no tools were used (if tools ran, interim was truly interim)
+ if not final_content and interim_content and not tools_used:
final_content = interim_content
break
From 37222f9c0a118c449f0ef291c380916e3ad7ae62 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Fri, 20 Feb 2026 09:38:22 -0300
Subject: [PATCH 218/506] fix: add connecting guard to prevent concurrent MCP
connection attempts
Addresses Codex review: concurrent callers could both pass the
_mcp_connected guard and race through _connect_mcp(). Added
_mcp_connecting flag set immediately to serialize attempts.
---
nanobot/agent/loop.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 9078318..d14b6ea 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -89,6 +89,7 @@ class AgentLoop:
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
+ self._mcp_connecting = False
self._consolidating: set[str] = set() # Session keys with consolidation in progress
self._register_default_tools()
@@ -126,8 +127,9 @@ class AgentLoop:
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
- if self._mcp_connected or not self._mcp_servers:
+ 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:
self._mcp_stack = AsyncExitStack()
@@ -142,6 +144,8 @@ class AgentLoop:
except Exception:
pass
self._mcp_stack = None
+ finally:
+ self._mcp_connecting = False
def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Update context for all tools that need routing info."""
From 4c75e1673fb546a9da3e3730a91fb6fe0165e70b Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Fri, 20 Feb 2026 09:55:22 -0300
Subject: [PATCH 219/506] fix: split Discord messages exceeding 2000-character
limit
Discord's API rejects messages longer than 2000 characters with HTTP 400.
Previously, long agent responses were silently lost after retries exhausted.
Adds _split_message() (matching Telegram's approach) to chunk content at
line boundaries before sending. Only the first chunk carries the reply
reference. Retry logic extracted to _send_payload() for reuse across chunks.
Closes #898
---
nanobot/channels/discord.py | 74 ++++++++++++++++++++++++++-----------
1 file changed, 52 insertions(+), 22 deletions(-)
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index 8baecbf..c073a05 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -17,6 +17,27 @@ 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 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 DiscordChannel(BaseChannel):
@@ -79,34 +100,43 @@ class DiscordChannel(BaseChannel):
return
url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages"
- payload: dict[str, Any] = {"content": msg.content}
-
- if msg.reply_to:
- payload["message_reference"] = {"message_id": msg.reply_to}
- payload["allowed_mentions"] = {"replied_user": False}
-
headers = {"Authorization": f"Bot {self.config.token}"}
try:
- 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
- except Exception as e:
- if attempt == 2:
- logger.error("Error sending Discord message: {}", e)
- else:
- await asyncio.sleep(1)
+ chunks = _split_message(msg.content or "")
+ 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}
+
+ await self._send_payload(url, headers, payload)
finally:
await self._stop_typing(msg.chat_id)
+ async def _send_payload(
+ self, url: str, headers: dict[str, str], payload: dict[str, Any]
+ ) -> None:
+ """Send a single Discord API payload with retry on rate-limit."""
+ 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
+ except Exception as e:
+ if attempt == 2:
+ logger.error("Error sending Discord message: {}", e)
+ else:
+ await asyncio.sleep(1)
+
async def _gateway_loop(self) -> None:
"""Main gateway loop: identify, heartbeat, dispatch events."""
if not self._ws:
From 73530d51acc6229ac3c7020a4129f06813478b83 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Fri, 20 Feb 2026 09:57:11 -0300
Subject: [PATCH 220/506] fix: store session key in JSONL metadata to avoid
lossy filename reconstruction
list_sessions() previously reconstructed the session key by replacing all
underscores in the filename with colons. This is lossy: a key like
'cli:user_name' became 'cli:user:name' after round-tripping.
Now the actual key is persisted in the metadata line during save() and read
back in list_sessions(). Legacy files without the key field fall back to
replacing only the first underscore, which handles the common channel:chat_id
pattern correctly.
Closes #899
---
nanobot/session/manager.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 9c1e427..35f8d13 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -154,6 +154,7 @@ class SessionManager:
with open(path, "w", encoding="utf-8") as f:
metadata_line = {
"_type": "metadata",
+ "key": session.key,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
"metadata": session.metadata,
@@ -186,8 +187,11 @@ class SessionManager:
if first_line:
data = json.loads(first_line)
if data.get("_type") == "metadata":
+ # Prefer the key stored in metadata; fall back to
+ # filename-based heuristic for legacy files.
+ key = data.get("key") or path.stem.replace("_", ":", 1)
sessions.append({
- "key": path.stem.replace("_", ":"),
+ "key": key,
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"path": str(path)
From 426ef71ce7a87d0c104bbf34e63e2399166bf83e Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Fri, 20 Feb 2026 13:57:39 +0100
Subject: [PATCH 221/506] style(loop): drop formatting-only churn against
upstream main
---
nanobot/agent/loop.py | 209 ++++++++++++++++--------------------------
1 file changed, 80 insertions(+), 129 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 42ab351..06ab1f7 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -6,7 +6,7 @@ import json
import json_repair
from pathlib import Path
import re
-from typing import Awaitable, Callable
+from typing import Any, Awaitable, Callable
from loguru import logger
@@ -56,7 +56,6 @@ class AgentLoop:
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
-
self.bus = bus
self.provider = provider
self.workspace = workspace
@@ -84,7 +83,7 @@ class AgentLoop:
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
-
+
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
@@ -93,7 +92,7 @@ class AgentLoop:
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 _register_default_tools(self) -> None:
"""Register the default set of tools."""
# File tools (workspace for relative paths, restrict if configured)
@@ -102,39 +101,36 @@ class AgentLoop:
self.tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
self.tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
-
+
# Shell tool
- self.tools.register(
- ExecTool(
- working_dir=str(self.workspace),
- timeout=self.exec_config.timeout,
- restrict_to_workspace=self.restrict_to_workspace,
- )
- )
-
+ self.tools.register(ExecTool(
+ working_dir=str(self.workspace),
+ timeout=self.exec_config.timeout,
+ restrict_to_workspace=self.restrict_to_workspace,
+ ))
+
# Web tools
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
-
+
# Message tool
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
self.tools.register(message_tool)
-
+
# Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool)
-
+
# Cron tool (for scheduling)
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
-
+
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or not self._mcp_servers:
return
self._mcp_connected = True
from nanobot.agent.tools.mcp import connect_mcp_servers
-
self._mcp_stack = AsyncExitStack()
await self._mcp_stack.__aenter__()
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
@@ -163,13 +159,11 @@ class AgentLoop:
@staticmethod
def _tool_hint(tool_calls: list) -> str:
"""Format tool calls as concise hint, e.g. '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(
@@ -217,15 +211,13 @@ class AgentLoop:
"type": "function",
"function": {
"name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False),
- },
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False)
+ }
}
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
- messages,
- response.content,
- tool_call_dicts,
+ messages, response.content, tool_call_dicts,
reasoning_content=response.reasoning_content,
)
@@ -243,13 +235,9 @@ class AgentLoop:
# Give them one retry; don't forward the text to avoid duplicates.
if not tools_used and not text_only_retried and final_content:
text_only_retried = True
- logger.debug(
- "Interim text response (no tools used yet), retrying: {}",
- final_content[:80],
- )
+ logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80])
messages = self.context.add_assistant_message(
- messages,
- response.content,
+ messages, response.content,
reasoning_content=response.reasoning_content,
)
final_content = None
@@ -266,23 +254,24 @@ class AgentLoop:
while self._running:
try:
- msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
+ msg = await asyncio.wait_for(
+ self.bus.consume_inbound(),
+ timeout=1.0
+ )
try:
response = await self._process_message(msg)
if response:
await self.bus.publish_outbound(response)
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)}",
- )
- )
+ 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:
"""Close MCP connections."""
if self._mcp_stack:
@@ -311,7 +300,7 @@ class AgentLoop:
if lock.locked() or has_waiters:
return
self._consolidation_locks.pop(session_key, None)
-
+
async def _process_message(
self,
msg: InboundMessage,
@@ -320,30 +309,30 @@ class AgentLoop:
) -> OutboundMessage | None:
"""
Process a single inbound message.
-
+
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
on_progress: Optional callback for intermediate output (defaults to bus publish).
-
+
Returns:
The response message, or None if no response needed.
"""
# System messages route back via chat_id ("channel:chat_id")
if msg.channel == "system":
return await self._process_system_message(msg)
-
+
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)
-
+
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
lock = self._get_consolidation_lock(session.key)
- messages_to_archive = []
+ messages_to_archive: list[dict[str, Any]] = []
try:
async with lock:
messages_to_archive = session.messages[session.last_consolidated :].copy()
@@ -369,18 +358,12 @@ class AgentLoop:
self.sessions.save(session)
self.sessions.invalidate(session.key)
self._prune_consolidation_lock(session.key, lock)
- return OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="New session started. Memory consolidation in progress.",
- )
+ return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
+ content="New session started. Memory consolidation in progress.")
if cmd == "/help":
- return OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands",
- )
-
+ return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
+ content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
+
if len(session.messages) > self.memory_window and session.key not in self._consolidating:
self._consolidating.add(session.key)
lock = self._get_consolidation_lock(session.key)
@@ -409,49 +392,42 @@ class AgentLoop:
)
async def _bus_progress(content: str) -> None:
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content=content,
- metadata=msg.metadata or {},
- )
- )
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel, chat_id=msg.chat_id, content=content,
+ metadata=msg.metadata or {},
+ ))
final_content, tools_used = await self._run_agent_loop(
- initial_messages,
- on_progress=on_progress or _bus_progress,
+ initial_messages, on_progress=on_progress or _bus_progress,
)
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)
-
+
session.add_message("user", msg.content)
- session.add_message(
- "assistant", final_content, tools_used=tools_used if tools_used else None
- )
+ session.add_message("assistant", final_content,
+ tools_used=tools_used if tools_used else None)
self.sessions.save(session)
-
+
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
- metadata=msg.metadata
- or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
+ metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
-
+
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
"""
Process a system message (e.g., subagent announce).
-
+
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
logger.info("Processing system message from {}", msg.sender_id)
-
+
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg.chat_id:
parts = msg.chat_id.split(":", 1)
@@ -461,7 +437,7 @@ class AgentLoop:
# Fallback
origin_channel = "cli"
origin_chat_id = msg.chat_id
-
+
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
self._set_tool_context(origin_channel, origin_chat_id, msg.metadata.get("message_id"))
@@ -475,15 +451,17 @@ class AgentLoop:
if final_content is None:
final_content = "Background task completed."
-
+
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self.sessions.save(session)
-
+
return OutboundMessage(
- channel=origin_channel, chat_id=origin_chat_id, content=final_content
+ channel=origin_channel,
+ chat_id=origin_chat_id,
+ content=final_content
)
-
+
async def _consolidate_memory(self, session, archive_all: bool = False) -> bool:
"""Consolidate old messages into MEMORY.md + HISTORY.md.
@@ -496,49 +474,29 @@ class AgentLoop:
if archive_all:
old_messages = session.messages
keep_count = 0
- logger.info(
- "Memory consolidation (archive_all): {} total messages archived",
- len(session.messages),
- )
+ logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages))
else:
keep_count = self.memory_window // 2
if len(session.messages) <= keep_count:
- logger.debug(
- "Session {}: No consolidation needed (messages={}, keep={})",
- session.key,
- len(session.messages),
- keep_count,
- )
+ logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count)
return True
messages_to_process = len(session.messages) - session.last_consolidated
if messages_to_process <= 0:
- logger.debug(
- "Session {}: No new messages to consolidate (last_consolidated={}, total={})",
- session.key,
- session.last_consolidated,
- len(session.messages),
- )
+ logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages))
return True
- old_messages = session.messages[session.last_consolidated : -keep_count]
+ old_messages = session.messages[session.last_consolidated:-keep_count]
if not old_messages:
return True
- logger.info(
- "Memory consolidation started: {} total, {} new to consolidate, {} keep",
- len(session.messages),
- len(old_messages),
- keep_count,
- )
+ logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), 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']}"
- )
+ lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
conversation = "\n".join(lines)
current_memory = memory.read_long_term()
@@ -567,10 +525,7 @@ Respond with ONLY valid JSON, no markdown fences."""
try:
response = await self.provider.chat(
messages=[
- {
- "role": "system",
- "content": "You are a memory consolidation agent. Respond only with valid JSON.",
- },
+ {"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
{"role": "user", "content": prompt},
],
model=self.model,
@@ -583,10 +538,7 @@ Respond with ONLY valid JSON, no markdown fences."""
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
result = json_repair.loads(text)
if not isinstance(result, dict):
- logger.warning(
- "Memory consolidation: unexpected response type, skipping. Response: {}",
- text[:200],
- )
+ logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200])
return False
if entry := result.get("history_entry"):
@@ -605,11 +557,7 @@ Respond with ONLY valid JSON, no markdown fences."""
session.last_consolidated = 0
else:
session.last_consolidated = len(session.messages) - keep_count
- logger.info(
- "Memory consolidation done: {} messages, last_consolidated={}",
- len(session.messages),
- session.last_consolidated,
- )
+ logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
return True
except Exception as e:
logger.error("Memory consolidation failed: {}", e)
@@ -625,21 +573,24 @@ Respond with ONLY valid JSON, no markdown fences."""
) -> str:
"""
Process a message directly (for CLI or cron usage).
-
+
Args:
content: The message content.
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
on_progress: Optional callback for intermediate output.
-
+
Returns:
The agent's response.
"""
await self._connect_mcp()
- msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
-
- response = await self._process_message(
- msg, session_key=session_key, on_progress=on_progress
+ msg = InboundMessage(
+ channel=channel,
+ sender_id="user",
+ chat_id=chat_id,
+ content=content
)
+
+ response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""
From f19baa8fc40f5a2a66c07d4e02847280dfbd55da Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Fri, 20 Feb 2026 10:01:38 -0300
Subject: [PATCH 222/506] fix: convert remaining f-string logger calls to
loguru native format
Follow-up to #864. Three f-string logger calls in base.py and dingtalk.py
were missed in the original sweep. These can cause KeyError if interpolated
values contain curly braces, since loguru interprets them as format placeholders.
---
nanobot/channels/base.py | 5 +++--
nanobot/channels/dingtalk.py | 6 ++++--
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py
index 30fcd1a..3a5a785 100644
--- a/nanobot/channels/base.py
+++ b/nanobot/channels/base.py
@@ -105,8 +105,9 @@ class BaseChannel(ABC):
"""
if not self.is_allowed(sender_id):
logger.warning(
- f"Access denied for sender {sender_id} on channel {self.name}. "
- f"Add them to allowFrom list in config to grant access."
+ "Access denied for sender {} on channel {}. "
+ "Add them to allowFrom list in config to grant access.",
+ sender_id, self.name,
)
return
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index b7263b3..09c7714 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -58,7 +58,8 @@ class NanobotDingTalkHandler(CallbackHandler):
if not content:
logger.warning(
- f"Received empty or unsupported message type: {chatbot_msg.message_type}"
+ "Received empty or unsupported message type: {}",
+ chatbot_msg.message_type,
)
return AckMessage.STATUS_OK, "OK"
@@ -126,7 +127,8 @@ class DingTalkChannel(BaseChannel):
self._http = httpx.AsyncClient()
logger.info(
- f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}..."
+ "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)
From 4cbd8572504320f9ef54948db9d5fbbca7130e68 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Fri, 20 Feb 2026 10:09:04 -0300
Subject: [PATCH 223/506] fix: handle edge cases in message splitting and send
failure
- _split_message: return empty list for empty/None content instead
of a list with one empty string (Discord rejects empty content)
- _split_message: use pos <= 0 fallback to prevent empty chunks
when content starts with a newline or space
- _send_payload: return bool to indicate success/failure
- send: abort remaining chunks when a chunk fails to send,
preventing partial/corrupted message delivery
---
nanobot/channels/discord.py | 22 ++++++++++++++++------
1 file changed, 16 insertions(+), 6 deletions(-)
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index c073a05..5a1cf5c 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -22,6 +22,8 @@ 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] = []
@@ -31,9 +33,9 @@ def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]:
break
cut = content[:max_len]
pos = cut.rfind('\n')
- if pos == -1:
+ if pos <= 0:
pos = cut.rfind(' ')
- if pos == -1:
+ if pos <= 0:
pos = max_len
chunks.append(content[:pos])
content = content[pos:].lstrip()
@@ -104,6 +106,9 @@ class DiscordChannel(BaseChannel):
try:
chunks = _split_message(msg.content or "")
+ if not chunks:
+ return
+
for i, chunk in enumerate(chunks):
payload: dict[str, Any] = {"content": chunk}
@@ -112,14 +117,18 @@ class DiscordChannel(BaseChannel):
payload["message_reference"] = {"message_id": msg.reply_to}
payload["allowed_mentions"] = {"replied_user": False}
- await self._send_payload(url, headers, payload)
+ 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]
- ) -> None:
- """Send a single Discord API payload with retry on rate-limit."""
+ ) -> bool:
+ """Send a single Discord API payload with retry on rate-limit.
+
+ Returns True on success, False if all attempts failed.
+ """
for attempt in range(3):
try:
response = await self._http.post(url, headers=headers, json=payload)
@@ -130,12 +139,13 @@ class DiscordChannel(BaseChannel):
await asyncio.sleep(retry_after)
continue
response.raise_for_status()
- return
+ 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."""
From b286457c854fd3d60228b680ec0dbc8d29286303 Mon Sep 17 00:00:00 2001
From: tercerapersona
Date: Fri, 20 Feb 2026 11:34:50 -0300
Subject: [PATCH 224/506] add Openrouter prompt caching via cache_control
---
nanobot/providers/registry.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 445d977..ecf092f 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -100,6 +100,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://openrouter.ai/api/v1",
strip_model_prefix=False,
model_overrides=(),
+ supports_prompt_caching=True,
),
# AiHubMix: global gateway, OpenAI-compatible interface.
From cc04bc4dd1806f30e2f622a2628ddb40afc84fe7 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 15:14:45 +0000
Subject: [PATCH 225/506] fix: check gateway's supports_prompt_caching instead
of always returning False
---
nanobot/providers/litellm_provider.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index edeb5c6..58c9ac2 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -111,7 +111,7 @@ class LiteLLMProvider(LLMProvider):
def _supports_cache_control(self, model: str) -> bool:
"""Return True when the provider supports cache_control on content blocks."""
if self._gateway is not None:
- return False
+ return self._gateway.supports_prompt_caching
spec = find_by_model(model)
return spec is not None and spec.supports_prompt_caching
From 6bcfbd9610687875c53ce65e64538e7200913591 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 15:19:18 +0000
Subject: [PATCH 226/506] style: remove redundant comments and use loguru
native format
---
nanobot/channels/slack.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index 26a3966..4fc1f41 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -86,7 +86,6 @@ class SlackChannel(BaseChannel):
use_thread = thread_ts and channel_type != "im"
thread_ts_param = thread_ts if use_thread else None
- # Send text message if content is present
if msg.content:
await self._web_client.chat_postMessage(
channel=msg.chat_id,
@@ -94,7 +93,6 @@ class SlackChannel(BaseChannel):
thread_ts=thread_ts_param,
)
- # Upload media files if present
for media_path in msg.media or []:
try:
await self._web_client.files_upload_v2(
@@ -103,7 +101,7 @@ class SlackChannel(BaseChannel):
thread_ts=thread_ts_param,
)
except Exception as e:
- logger.error(f"Failed to upload file {media_path}: {e}")
+ logger.error("Failed to upload file {}: {}", media_path, e)
except Exception as e:
logger.error("Error sending Slack message: {}", e)
From b853222c8755bfb3405735b13bc2c1deae20d437 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 15:26:12 +0000
Subject: [PATCH 227/506] style: trim _send_payload docstring
---
nanobot/channels/discord.py | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index 5a1cf5c..1d2d7a6 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -125,10 +125,7 @@ class DiscordChannel(BaseChannel):
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, False if all attempts failed.
- """
+ """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)
From d9cc144575a5186b9ae6be8ff520d981f1f12fe5 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 15:42:24 +0000
Subject: [PATCH 228/506] style: remove redundant comment in list_sessions
---
nanobot/session/manager.py | 2 --
1 file changed, 2 deletions(-)
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 35f8d13..18e23b2 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -187,8 +187,6 @@ class SessionManager:
if first_line:
data = json.loads(first_line)
if data.get("_type") == "metadata":
- # Prefer the key stored in metadata; fall back to
- # filename-based heuristic for legacy files.
key = data.get("key") or path.stem.replace("_", ":", 1)
sessions.append({
"key": key,
From 132807a3fbbd851cfef2c21f469e32216aa18cb7 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 15:55:30 +0000
Subject: [PATCH 229/506] refactor: simplify message tool turn tracking to a
single boolean flag
---
nanobot/agent/loop.py | 14 ++------------
nanobot/agent/tools/message.py | 11 ++++-------
2 files changed, 6 insertions(+), 19 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 646b658..51d0bc0 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -383,19 +383,9 @@ class AgentLoop:
tools_used=tools_used if tools_used else None)
self.sessions.save(session)
- suppress_final_reply = False
if message_tool := self.tools.get("message"):
- if isinstance(message_tool, MessageTool):
- sent_targets = set(message_tool.get_turn_sends())
- suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets
-
- if suppress_final_reply:
- logger.info(
- "Skipping final auto-reply because message tool already sent to {}:{} in this turn",
- msg.channel,
- msg.chat_id,
- )
- return None
+ if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
+ return None
return OutboundMessage(
channel=msg.channel,
diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py
index 593bc44..40e76e3 100644
--- a/nanobot/agent/tools/message.py
+++ b/nanobot/agent/tools/message.py
@@ -20,24 +20,21 @@ class MessageTool(Tool):
self._default_channel = default_channel
self._default_chat_id = default_chat_id
self._default_message_id = default_message_id
- self._turn_sends: list[tuple[str, str]] = []
+ 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._turn_sends.clear()
-
- def get_turn_sends(self) -> list[tuple[str, str]]:
- """Get (channel, chat_id) targets sent in the current turn."""
- return list(self._turn_sends)
+ self._sent_in_turn = False
@property
def name(self) -> str:
@@ -104,7 +101,7 @@ class MessageTool(Tool):
try:
await self._send_callback(msg)
- self._turn_sends.append((channel, chat_id))
+ 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:
From 7279ff0167fbbdcd06f380b0c52e69371f95b73b Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 16:45:21 +0000
Subject: [PATCH 230/506] refactor: route CLI interactive mode through message
bus for subagent support
---
nanobot/agent/loop.py | 9 ++++--
nanobot/cli/commands.py | 62 +++++++++++++++++++++++++++++++++++------
2 files changed, 60 insertions(+), 11 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index e5ed6e4..9f1e265 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -277,8 +277,9 @@ class AgentLoop:
)
try:
response = await self._process_message(msg)
- if response:
- await self.bus.publish_outbound(response)
+ await self.bus.publish_outbound(response or OutboundMessage(
+ channel=msg.channel, chat_id=msg.chat_id, content="",
+ ))
except Exception as e:
logger.error("Error processing message: {}", e)
await self.bus.publish_outbound(OutboundMessage(
@@ -376,9 +377,11 @@ class AgentLoop:
)
async def _bus_progress(content: str) -> None:
+ meta = dict(msg.metadata or {})
+ meta["_progress"] = True
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
- metadata=msg.metadata or {},
+ metadata=meta,
))
final_content, tools_used = await self._run_agent_loop(
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index a135349..6155463 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -498,27 +498,58 @@ def agent(
console.print(f" [dim]↳ {content}[/dim]")
if message:
- # Single message mode
+ # Single message mode — direct call, no bus needed
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:
- # Interactive mode
+ # Interactive mode — route through bus like other channels
+ from nanobot.bus.events import InboundMessage
_init_prompt_session()
console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n")
+ 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):
_restore_terminal()
console.print("\nGoodbye!")
os._exit(0)
signal.signal(signal.SIGINT, _exit_on_sigint)
-
+
async def run_interactive():
+ bus_task = asyncio.create_task(agent_loop.run())
+ turn_done = asyncio.Event()
+ turn_done.set()
+ turn_response: list[str] = []
+
+ async def _consume_outbound():
+ while True:
+ try:
+ msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
+ if msg.metadata.get("_progress"):
+ 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:
+ continue
+ except asyncio.CancelledError:
+ break
+
+ outbound_task = asyncio.create_task(_consume_outbound())
+
try:
while True:
try:
@@ -532,10 +563,22 @@ def agent(
_restore_terminal()
console.print("\nGoodbye!")
break
-
+
+ turn_done.clear()
+ turn_response.clear()
+
+ await bus.publish_inbound(InboundMessage(
+ channel=cli_channel,
+ sender_id="user",
+ chat_id=cli_chat_id,
+ content=user_input,
+ ))
+
with _thinking_ctx():
- response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress)
- _print_agent_response(response, render_markdown=markdown)
+ await turn_done.wait()
+
+ if turn_response:
+ _print_agent_response(turn_response[0], render_markdown=markdown)
except KeyboardInterrupt:
_restore_terminal()
console.print("\nGoodbye!")
@@ -545,8 +588,11 @@ def agent(
console.print("\nGoodbye!")
break
finally:
+ 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())
From d3ddeb30671e7a5b33e5758bf6e33191662bc1e3 Mon Sep 17 00:00:00 2001
From: djmaze <7229+djmaze@users.noreply.github.com>
Date: Tue, 10 Feb 2026 02:01:15 +0100
Subject: [PATCH 231/506] fix: activate E2E and accept room invites in Matrix
channels
---
nanobot/channels/matrix.py | 48 ++++++++++++++++++++++++--------------
nanobot/config/schema.py | 12 ++++++++++
pyproject.toml | 1 +
3 files changed, 43 insertions(+), 18 deletions(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 0ea4ae1..f3b8468 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -1,10 +1,11 @@
import asyncio
from typing import Any
-from nio import AsyncClient, MatrixRoom, RoomMessageText
+from nio import AsyncClient, AsyncClientConfig, InviteEvent, MatrixRoom, RoomMessageText
-from nanobot.channels.base import BaseChannel
from nanobot.bus.events import OutboundMessage
+from nanobot.channels.base import BaseChannel
+from nanobot.config.loader import get_data_dir
class MatrixChannel(BaseChannel):
@@ -22,18 +23,28 @@ class MatrixChannel(BaseChannel):
async def start(self) -> None:
self._running = True
- self.client = AsyncClient(
- homeserver=self.config.homeserver,
- user=self.config.user_id,
- )
-
- self.client.access_token = self.config.access_token
+ store_path = get_data_dir() / "matrix-store"
+ store_path.mkdir(parents=True, exist_ok=True)
- self.client.add_event_callback(
- self._on_message,
- RoomMessageText
+ self.client = AsyncClient(
+ homeserver=self.config.homeserver,
+ user=self.config.user_id,
+ store_path=store_path, # Where tokens are saved
+ config=AsyncClientConfig(
+ store_sync_tokens=True, # Auto-persists next_batch tokens
+ encryption_enabled=True,
+ ),
)
+ 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.client.add_event_callback(self._on_message, RoomMessageText)
+ self.client.add_event_callback(self._on_room_invite, InviteEvent)
+
+ self.client.load_store()
+
self._sync_task = asyncio.create_task(self._sync_loop())
async def stop(self) -> None:
@@ -51,22 +62,23 @@ class MatrixChannel(BaseChannel):
room_id=msg.chat_id,
message_type="m.room.message",
content={"msgtype": "m.text", "body": msg.content},
+ ignore_unverified_devices=True,
)
async def _sync_loop(self) -> None:
while self._running:
try:
- await self.client.sync(timeout=30000)
+ await self.client.sync_forever(timeout=30000, full_state=True)
except asyncio.CancelledError:
break
except Exception:
await asyncio.sleep(2)
- async def _on_message(
- self,
- room: MatrixRoom,
- event: RoomMessageText
- ) -> None:
+ async def _on_room_invite(self, room: MatrixRoom, event: RoomMessageText) -> None:
+ if event.sender in self.config.allow_from:
+ await self.client.join(room.room_id)
+
+ async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
# Ignore self messages
if event.sender == self.config.user_id:
return
@@ -76,4 +88,4 @@ class MatrixChannel(BaseChannel):
chat_id=room.room_id,
content=event.body,
metadata={"room": room.display_name},
- )
\ No newline at end of file
+ )
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 6a1257e..8413f0a 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -60,6 +60,17 @@ class DiscordConfig(Base):
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
+class MatrixConfig(Base):
+ """Matrix (Element) channel configuration."""
+
+ enabled: bool = False
+ homeserver: str = "https://matrix.org"
+ access_token: str = ""
+ user_id: str = "" # @bot:matrix.org
+ device_id: str = ""
+ allow_from: list[str] = Field(default_factory=list)
+
+
class EmailConfig(Base):
"""Email channel configuration (IMAP inbound + SMTP outbound)."""
@@ -176,6 +187,7 @@ class ChannelsConfig(Base):
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):
diff --git a/pyproject.toml b/pyproject.toml
index 64a884d..18bbe70 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -42,6 +42,7 @@ dependencies = [
"prompt-toolkit>=3.0.50,<4.0.0",
"mcp>=1.26.0,<2.0.0",
"json-repair>=0.57.0,<1.0.0",
+ "matrix-nio[e2e]>=0.25.2",
]
[project.optional-dependencies]
From c926569033bce8f52d49d54bf23c0d77439c1936 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 09:05:20 +0100
Subject: [PATCH 232/506] fix(matrix): guard store load without device id and
allow invites by default
---
nanobot/channels/matrix.py | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index f3b8468..340ec2a 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -43,7 +43,8 @@ class MatrixChannel(BaseChannel):
self.client.add_event_callback(self._on_message, RoomMessageText)
self.client.add_event_callback(self._on_room_invite, InviteEvent)
- self.client.load_store()
+ if self.config.device_id:
+ self.client.load_store()
self._sync_task = asyncio.create_task(self._sync_loop())
@@ -74,9 +75,12 @@ class MatrixChannel(BaseChannel):
except Exception:
await asyncio.sleep(2)
- async def _on_room_invite(self, room: MatrixRoom, event: RoomMessageText) -> None:
- if event.sender in self.config.allow_from:
- await self.client.join(room.room_id)
+ async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
+ allow_from = self.config.allow_from or []
+ if allow_from and event.sender not in allow_from:
+ return
+
+ await self.client.join(room.room_id)
async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
# Ignore self messages
From 988b75624c872c5351d92c59ec9be35284007c75 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 09:22:32 +0100
Subject: [PATCH 233/506] test(matrix): add matrix channel behavior test
---
tests/test_matrix_channel.py | 113 +++++++++++++++++++++++++++++++++++
1 file changed, 113 insertions(+)
create mode 100644 tests/test_matrix_channel.py
diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py
new file mode 100644
index 0000000..bc39097
--- /dev/null
+++ b/tests/test_matrix_channel.py
@@ -0,0 +1,113 @@
+from types import SimpleNamespace
+
+import pytest
+
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.matrix import MatrixChannel
+from nanobot.config.schema import MatrixConfig
+
+
+class _DummyTask:
+ def __init__(self) -> None:
+ self.cancelled = False
+
+ def cancel(self) -> None:
+ self.cancelled = True
+
+
+class _FakeAsyncClient:
+ def __init__(self, homeserver, user, store_path, config) -> None:
+ self.homeserver = homeserver
+ self.user = user
+ self.store_path = store_path
+ self.config = config
+ self.user_id: str | None = None
+ self.access_token: str | None = None
+ self.device_id: str | None = None
+ self.load_store_called = False
+ self.join_calls: list[str] = []
+ self.callbacks: list[tuple[object, object]] = []
+
+ def add_event_callback(self, callback, event_type) -> None:
+ self.callbacks.append((callback, event_type))
+
+ def load_store(self) -> None:
+ self.load_store_called = True
+
+ async def join(self, room_id: str) -> None:
+ self.join_calls.append(room_id)
+
+ async def close(self) -> None:
+ return None
+
+
+def _make_config(**kwargs) -> MatrixConfig:
+ return MatrixConfig(
+ enabled=True,
+ homeserver="https://matrix.org",
+ access_token="token",
+ user_id="@bot:matrix.org",
+ **kwargs,
+ )
+
+
+@pytest.mark.asyncio
+async def test_start_skips_load_store_when_device_id_missing(
+ monkeypatch, tmp_path
+) -> None:
+ clients: list[_FakeAsyncClient] = []
+
+ def _fake_client(*args, **kwargs) -> _FakeAsyncClient:
+ client = _FakeAsyncClient(*args, **kwargs)
+ clients.append(client)
+ return client
+
+ def _fake_create_task(coro):
+ coro.close()
+ return _DummyTask()
+
+ monkeypatch.setattr("nanobot.channels.matrix.get_data_dir", lambda: tmp_path)
+ monkeypatch.setattr(
+ "nanobot.channels.matrix.AsyncClientConfig",
+ lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ monkeypatch.setattr("nanobot.channels.matrix.AsyncClient", _fake_client)
+ monkeypatch.setattr(
+ "nanobot.channels.matrix.asyncio.create_task", _fake_create_task
+ )
+
+ channel = MatrixChannel(_make_config(device_id=""), MessageBus())
+ await channel.start()
+
+ assert len(clients) == 1
+ assert clients[0].load_store_called is False
+
+ await channel.stop()
+
+
+@pytest.mark.asyncio
+async def test_room_invite_joins_when_allow_list_is_empty() -> None:
+ channel = MatrixChannel(_make_config(allow_from=[]), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ room = SimpleNamespace(room_id="!room:matrix.org")
+ event = SimpleNamespace(sender="@alice:matrix.org")
+
+ await channel._on_room_invite(room, event)
+
+ assert client.join_calls == ["!room:matrix.org"]
+
+
+@pytest.mark.asyncio
+async def test_room_invite_respects_allow_list_when_configured() -> None:
+ channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ room = SimpleNamespace(room_id="!room:matrix.org")
+ event = SimpleNamespace(sender="@alice:matrix.org")
+
+ await channel._on_room_invite(room, event)
+
+ assert client.join_calls == []
From 9a31571b6dd9e5aec13df46aebdd13d65ccb99ad Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Fri, 20 Feb 2026 16:49:40 +0000
Subject: [PATCH 234/506] fix: don't append interim assistant message before
retry to avoid prefill errors
---
nanobot/agent/loop.py | 4 ----
1 file changed, 4 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 9f1e265..4850f9c 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -253,10 +253,6 @@ class AgentLoop:
if not tools_used and not text_only_retried and final_content:
text_only_retried = True
logger.debug("Interim text response (no tools used yet), retrying: {}", final_content[:80])
- messages = self.context.add_assistant_message(
- messages, response.content,
- reasoning_content=response.reasoning_content,
- )
final_content = None
continue
break
From 7c33d3cbe241012eff5f5337912a2e994f0ceba1 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 12:20:55 +0100
Subject: [PATCH 235/506] feat(matrix): add configurable graceful sync shutdown
---
nanobot/channels/matrix.py | 21 ++++++++++++++++++++-
nanobot/config/schema.py | 22 +++++++++++++++++-----
2 files changed, 37 insertions(+), 6 deletions(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 340ec2a..7e84626 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -49,9 +49,28 @@ class MatrixChannel(BaseChannel):
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
+
+ if self.client:
+ # Request sync_forever loop to exit cleanly.
+ self.client.stop_sync_forever()
+
if self._sync_task:
- self._sync_task.cancel()
+ try:
+ await asyncio.wait_for(
+ asyncio.shield(self._sync_task),
+ timeout=self.config.sync_stop_grace_seconds,
+ )
+ except asyncio.TimeoutError:
+ self._sync_task.cancel()
+ try:
+ await self._sync_task
+ except asyncio.CancelledError:
+ pass
+ except asyncio.CancelledError:
+ pass
+
if self.client:
await self.client.close()
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 8413f0a..f8d251b 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -27,7 +27,9 @@ class TelegramConfig(Base):
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"
+ proxy: str | None = (
+ None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
+ )
class FeishuConfig(Base):
@@ -68,6 +70,8 @@ class MatrixConfig(Base):
access_token: str = ""
user_id: str = "" # @bot:matrix.org
device_id: str = ""
+ # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
+ sync_stop_grace_seconds: int = 2
allow_from: list[str] = Field(default_factory=list)
@@ -95,7 +99,9 @@ class EmailConfig(Base):
from_address: str = ""
# Behavior
- auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent
+ 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
@@ -172,7 +178,9 @@ class QQConfig(Base):
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)
+ allow_from: list[str] = Field(
+ default_factory=list
+ ) # Allowed user openids (empty = public access)
class ChannelsConfig(Base):
@@ -231,7 +239,9 @@ class ProvidersConfig(Base):
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
+ siliconflow: ProviderConfig = Field(
+ default_factory=ProviderConfig
+ ) # SiliconFlow (硅基流动) API gateway
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
@@ -294,7 +304,9 @@ class Config(BaseSettings):
"""Get expanded workspace path."""
return Path(self.agents.defaults.workspace).expanduser()
- def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]:
+ def _match_provider(
+ self, model: str | None = None
+ ) -> tuple["ProviderConfig | None", str | None]:
"""Match provider config and its registry name. Returns (config, spec_name)."""
from nanobot.providers.registry import PROVIDERS
From 9d8539322657f35f22c84036536f088a995d4b4c Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 12:24:36 +0100
Subject: [PATCH 236/506] feat(matrix): add startup warnings and response error
logging
---
nanobot/channels/matrix.py | 71 +++++++++++++++++++++++++++++++++++---
1 file changed, 67 insertions(+), 4 deletions(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 7e84626..89e7616 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -1,7 +1,17 @@
import asyncio
from typing import Any
-from nio import AsyncClient, AsyncClientConfig, InviteEvent, MatrixRoom, RoomMessageText
+from loguru import logger
+from nio import (
+ AsyncClient,
+ AsyncClientConfig,
+ InviteEvent,
+ JoinError,
+ MatrixRoom,
+ RoomMessageText,
+ RoomSendError,
+ SyncError,
+)
from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel
@@ -21,6 +31,7 @@ class MatrixChannel(BaseChannel):
self._sync_task: asyncio.Task | None = None
async def start(self) -> None:
+ """Start Matrix client and begin sync loop."""
self._running = True
store_path = get_data_dir() / "matrix-store"
@@ -40,11 +51,24 @@ class MatrixChannel(BaseChannel):
self.client.access_token = self.config.access_token
self.client.device_id = self.config.device_id
- self.client.add_event_callback(self._on_message, RoomMessageText)
- self.client.add_event_callback(self._on_room_invite, InviteEvent)
+ self._register_event_callbacks()
+ self._register_response_callbacks()
if self.config.device_id:
- self.client.load_store()
+ try:
+ self.client.load_store()
+ except Exception as e:
+ logger.warning(
+ "Matrix store load failed ({}: {}); sync token restore is disabled and "
+ "restart may replay recent messages.",
+ type(e).__name__,
+ str(e),
+ )
+ else:
+ logger.warning(
+ "Matrix device_id is empty; sync token restore is disabled and restart may "
+ "replay recent messages."
+ )
self._sync_task = asyncio.create_task(self._sync_loop())
@@ -85,9 +109,48 @@ class MatrixChannel(BaseChannel):
ignore_unverified_devices=True,
)
+ def _register_event_callbacks(self) -> None:
+ """Register Matrix event callbacks used by this channel."""
+ self.client.add_event_callback(self._on_message, RoomMessageText)
+ self.client.add_event_callback(self._on_room_invite, InviteEvent)
+
+ def _register_response_callbacks(self) -> None:
+ """Register response callbacks for operational error observability."""
+ 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)
+
+ @staticmethod
+ def _is_auth_error(errcode: str | None) -> bool:
+ """Return True if the Matrix errcode indicates auth/token problems."""
+ return errcode in {"M_UNKNOWN_TOKEN", "M_FORBIDDEN", "M_UNAUTHORIZED"}
+
+ async def _on_sync_error(self, response: SyncError) -> None:
+ """Log sync errors with clear severity."""
+ if self._is_auth_error(response.status_code) or response.soft_logout:
+ logger.error("Matrix sync failed: {}", response)
+ return
+ logger.warning("Matrix sync warning: {}", response)
+
+ async def _on_join_error(self, response: JoinError) -> None:
+ """Log room-join errors from invite handling."""
+ if self._is_auth_error(response.status_code):
+ logger.error("Matrix join failed: {}", response)
+ return
+ logger.warning("Matrix join warning: {}", response)
+
+ async def _on_send_error(self, response: RoomSendError) -> None:
+ """Log message send failures."""
+ if self._is_auth_error(response.status_code):
+ logger.error("Matrix send failed: {}", response)
+ return
+ logger.warning("Matrix send warning: {}", response)
+
async def _sync_loop(self) -> None:
while self._running:
try:
+ # full_state applies only to the first sync inside sync_forever and helps
+ # rebuild room state when restoring from stored sync tokens.
await self.client.sync_forever(timeout=30000, full_state=True)
except asyncio.CancelledError:
break
From b721f9f37dc295d30d45e81746335ae34b54f5e1 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 12:24:54 +0100
Subject: [PATCH 237/506] test(matrix): cover response callbacks and graceful
shutdown
---
tests/test_matrix_channel.py | 32 ++++++++++++++++++++++++++++++++
1 file changed, 32 insertions(+)
diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py
index bc39097..f86543b 100644
--- a/tests/test_matrix_channel.py
+++ b/tests/test_matrix_channel.py
@@ -14,6 +14,12 @@ class _DummyTask:
def cancel(self) -> None:
self.cancelled = True
+ def __await__(self):
+ async def _done():
+ return None
+
+ return _done().__await__()
+
class _FakeAsyncClient:
def __init__(self, homeserver, user, store_path, config) -> None:
@@ -25,15 +31,23 @@ class _FakeAsyncClient:
self.access_token: str | None = None
self.device_id: str | None = None
self.load_store_called = False
+ self.stop_sync_forever_called = False
self.join_calls: list[str] = []
self.callbacks: list[tuple[object, object]] = []
+ self.response_callbacks: list[tuple[object, object]] = []
def add_event_callback(self, callback, event_type) -> None:
self.callbacks.append((callback, event_type))
+ def add_response_callback(self, callback, response_type) -> None:
+ self.response_callbacks.append((callback, response_type))
+
def load_store(self) -> None:
self.load_store_called = True
+ def stop_sync_forever(self) -> None:
+ self.stop_sync_forever_called = True
+
async def join(self, room_id: str) -> None:
self.join_calls.append(room_id)
@@ -81,10 +95,28 @@ async def test_start_skips_load_store_when_device_id_missing(
assert len(clients) == 1
assert clients[0].load_store_called is False
+ assert len(clients[0].response_callbacks) == 3
await channel.stop()
+@pytest.mark.asyncio
+async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None:
+ channel = MatrixChannel(_make_config(device_id="DEVICE"), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ task = _DummyTask()
+
+ channel.client = client
+ channel._sync_task = task
+ channel._running = True
+
+ await channel.stop()
+
+ assert channel._running is False
+ assert client.stop_sync_forever_called is True
+ assert task.cancelled is False
+
+
@pytest.mark.asyncio
async def test_room_invite_joins_when_allow_list_is_empty() -> None:
channel = MatrixChannel(_make_config(allow_from=[]), MessageBus())
From b294a682a86631452e6f15e809e7933d7007bb03 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 14:03:13 +0100
Subject: [PATCH 238/506] chore(matrix): route matrix-nio logs through loguru
---
nanobot/channels/matrix.py | 30 ++++++++++++++++++++++++++++++
1 file changed, 30 insertions(+)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 89e7616..d73a849 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -1,4 +1,5 @@
import asyncio
+import logging
from typing import Any
from loguru import logger
@@ -18,6 +19,34 @@ from nanobot.channels.base import BaseChannel
from nanobot.config.loader import get_data_dir
+class _NioLoguruHandler(logging.Handler):
+ """Route stdlib logging records from matrix-nio into Loguru output."""
+
+ def emit(self, record: logging.LogRecord) -> None:
+ try:
+ level = logger.level(record.levelname).name
+ except ValueError:
+ level = record.levelno
+
+ frame = logging.currentframe()
+ depth = 2
+ while frame and frame.f_code.co_filename == logging.__file__:
+ frame = frame.f_back
+ depth += 1
+
+ logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
+
+
+def _configure_nio_logging_bridge() -> None:
+ """Ensure matrix-nio logs are emitted through the project's Loguru format."""
+ nio_logger = logging.getLogger("nio")
+ if any(isinstance(handler, _NioLoguruHandler) for handler in nio_logger.handlers):
+ return
+
+ nio_logger.handlers = [_NioLoguruHandler()]
+ nio_logger.propagate = False
+
+
class MatrixChannel(BaseChannel):
"""
Matrix (Element) channel using long-polling sync.
@@ -33,6 +62,7 @@ class MatrixChannel(BaseChannel):
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)
From ffac42f9e5d1d3ebac690f29f0354b06acb961c8 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 14:12:45 +0100
Subject: [PATCH 239/506] refactor(matrix): replace logging depth magic number
---
nanobot/channels/matrix.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index d73a849..63a07e0 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -18,6 +18,8 @@ from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel
from nanobot.config.loader import get_data_dir
+LOGGING_STACK_BASE_DEPTH = 2
+
class _NioLoguruHandler(logging.Handler):
"""Route stdlib logging records from matrix-nio into Loguru output."""
@@ -29,7 +31,8 @@ class _NioLoguruHandler(logging.Handler):
level = record.levelno
frame = logging.currentframe()
- depth = 2
+ # Skip logging internals plus this handler frame when forwarding to Loguru.
+ depth = LOGGING_STACK_BASE_DEPTH
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
From 45267b0730b9a2b07b4b0ad1676fa9ba85f88898 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 14:25:12 +0100
Subject: [PATCH 240/506] feat(matrix): show typing while processing messages
---
nanobot/channels/matrix.py | 65 +++++++++++++++++++++++++++++++-------
1 file changed, 53 insertions(+), 12 deletions(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 63a07e0..60a86fc 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -11,6 +11,7 @@ from nio import (
MatrixRoom,
RoomMessageText,
RoomSendError,
+ RoomTypingError,
SyncError,
)
@@ -19,6 +20,7 @@ from nanobot.channels.base import BaseChannel
from nanobot.config.loader import get_data_dir
LOGGING_STACK_BASE_DEPTH = 2
+TYPING_NOTICE_TIMEOUT_MS = 30_000
class _NioLoguruHandler(logging.Handler):
@@ -135,12 +137,15 @@ class MatrixChannel(BaseChannel):
if not self.client:
return
- await self.client.room_send(
- room_id=msg.chat_id,
- message_type="m.room.message",
- content={"msgtype": "m.text", "body": msg.content},
- ignore_unverified_devices=True,
- )
+ try:
+ await self.client.room_send(
+ room_id=msg.chat_id,
+ message_type="m.room.message",
+ content={"msgtype": "m.text", "body": msg.content},
+ ignore_unverified_devices=True,
+ )
+ finally:
+ await self._set_typing(msg.chat_id, False)
def _register_event_callbacks(self) -> None:
"""Register Matrix event callbacks used by this channel."""
@@ -179,6 +184,28 @@ class MatrixChannel(BaseChannel):
return
logger.warning("Matrix send warning: {}", response)
+ async def _set_typing(self, room_id: str, typing: bool) -> None:
+ """Best-effort typing indicator update that never blocks message flow."""
+ 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 update failed for room {}: {}", room_id, response)
+ except Exception as e:
+ logger.debug(
+ "Matrix typing update failed for room {} (typing={}): {}: {}",
+ room_id,
+ typing,
+ type(e).__name__,
+ str(e),
+ )
+
async def _sync_loop(self) -> None:
while self._running:
try:
@@ -202,9 +229,23 @@ class MatrixChannel(BaseChannel):
if event.sender == self.config.user_id:
return
- await self._handle_message(
- sender_id=event.sender,
- chat_id=room.room_id,
- content=event.body,
- metadata={"room": room.display_name},
- )
+ if not self.is_allowed(event.sender):
+ await self._handle_message(
+ sender_id=event.sender,
+ chat_id=room.room_id,
+ content=event.body,
+ metadata={"room": room.display_name},
+ )
+ return
+
+ await self._set_typing(room.room_id, True)
+ try:
+ await self._handle_message(
+ sender_id=event.sender,
+ chat_id=room.room_id,
+ content=event.body,
+ metadata={"room": room.display_name},
+ )
+ except Exception:
+ await self._set_typing(room.room_id, False)
+ raise
From 840ef7363f3c51d1d9746abdbe82989a41f4193d Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 14:25:33 +0100
Subject: [PATCH 241/506] test(matrix): cover typing indicator lifecycle
---
tests/test_matrix_channel.py | 116 ++++++++++++++++++++++++++++++++++-
1 file changed, 115 insertions(+), 1 deletion(-)
diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py
index f86543b..b2accbb 100644
--- a/tests/test_matrix_channel.py
+++ b/tests/test_matrix_channel.py
@@ -2,8 +2,9 @@ from types import SimpleNamespace
import pytest
+from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
-from nanobot.channels.matrix import MatrixChannel
+from nanobot.channels.matrix import TYPING_NOTICE_TIMEOUT_MS, MatrixChannel
from nanobot.config.schema import MatrixConfig
@@ -35,6 +36,10 @@ class _FakeAsyncClient:
self.join_calls: list[str] = []
self.callbacks: list[tuple[object, object]] = []
self.response_callbacks: list[tuple[object, object]] = []
+ self.room_send_calls: list[dict[str, object]] = []
+ self.typing_calls: list[tuple[str, bool, int]] = []
+ self.raise_on_send = False
+ self.raise_on_typing = False
def add_event_callback(self, callback, event_type) -> None:
self.callbacks.append((callback, event_type))
@@ -51,6 +56,34 @@ class _FakeAsyncClient:
async def join(self, room_id: str) -> None:
self.join_calls.append(room_id)
+ async def room_send(
+ self,
+ room_id: str,
+ message_type: str,
+ content: dict[str, object],
+ ignore_unverified_devices: bool,
+ ) -> None:
+ self.room_send_calls.append(
+ {
+ "room_id": room_id,
+ "message_type": message_type,
+ "content": content,
+ "ignore_unverified_devices": ignore_unverified_devices,
+ }
+ )
+ if self.raise_on_send:
+ raise RuntimeError("send failed")
+
+ async def room_typing(
+ self,
+ room_id: str,
+ typing_state: bool = True,
+ timeout: int = 30_000,
+ ) -> None:
+ self.typing_calls.append((room_id, typing_state, timeout))
+ if self.raise_on_typing:
+ raise RuntimeError("typing failed")
+
async def close(self) -> None:
return None
@@ -143,3 +176,84 @@ async def test_room_invite_respects_allow_list_when_configured() -> None:
await channel._on_room_invite(room, event)
assert client.join_calls == []
+
+
+@pytest.mark.asyncio
+async def test_on_message_sets_typing_for_allowed_sender() -> None:
+ channel = MatrixChannel(_make_config(), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ handled: list[str] = []
+
+ async def _fake_handle_message(**kwargs) -> None:
+ handled.append(kwargs["sender_id"])
+
+ channel._handle_message = _fake_handle_message # type: ignore[method-assign]
+
+ room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
+ event = SimpleNamespace(sender="@alice:matrix.org", body="Hello")
+
+ await channel._on_message(room, event)
+
+ assert handled == ["@alice:matrix.org"]
+ assert client.typing_calls == [
+ ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_on_message_skips_typing_for_self_message() -> None:
+ channel = MatrixChannel(_make_config(), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
+ event = SimpleNamespace(sender="@bot:matrix.org", body="Hello")
+
+ await channel._on_message(room, event)
+
+ assert client.typing_calls == []
+
+
+@pytest.mark.asyncio
+async def test_on_message_skips_typing_for_denied_sender() -> None:
+ channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
+ event = SimpleNamespace(sender="@alice:matrix.org", body="Hello")
+
+ await channel._on_message(room, event)
+
+ assert client.typing_calls == []
+
+
+@pytest.mark.asyncio
+async def test_send_clears_typing_after_send() -> None:
+ channel = MatrixChannel(_make_config(), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ await channel.send(
+ OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi")
+ )
+
+ assert len(client.room_send_calls) == 1
+ assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
+
+
+@pytest.mark.asyncio
+async def test_send_clears_typing_when_send_fails() -> None:
+ channel = MatrixChannel(_make_config(), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ client.raise_on_send = True
+ channel.client = client
+
+ with pytest.raises(RuntimeError, match="send failed"):
+ await channel.send(
+ OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content="Hi")
+ )
+
+ assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
From e716c9caaccde37e74e2ae4f22f1e3086b4b9c0d Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 15:02:03 +0100
Subject: [PATCH 242/506] feat(matrix): send markdown as formatted html
messages
---
nanobot/channels/matrix.py | 44 +++++++++++++++++++++++++++++++++++++-
pyproject.toml | 1 +
2 files changed, 44 insertions(+), 1 deletion(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 60a86fc..0cf354c 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -3,6 +3,7 @@ import logging
from typing import Any
from loguru import logger
+from mistune import create_markdown
from nio import (
AsyncClient,
AsyncClientConfig,
@@ -21,6 +22,47 @@ from nanobot.config.loader import get_data_dir
LOGGING_STACK_BASE_DEPTH = 2
TYPING_NOTICE_TIMEOUT_MS = 30_000
+MATRIX_HTML_FORMAT = "org.matrix.custom.html"
+
+MATRIX_MARKDOWN = create_markdown(
+ escape=True,
+ plugins=["table", "strikethrough", "task_lists"],
+)
+
+
+def _render_markdown_html(text: str) -> str | None:
+ """Render markdown to HTML for Matrix formatted messages."""
+ try:
+ formatted = MATRIX_MARKDOWN(text).strip()
+ except Exception as e:
+ logger.debug(
+ "Matrix markdown rendering failed ({}): {}",
+ type(e).__name__,
+ str(e),
+ )
+ return None
+
+ if not formatted:
+ return None
+
+ # Skip formatted_body for plain output (...
) to keep payload minimal.
+ stripped = formatted.strip()
+ if stripped.startswith("") and stripped.endswith("
") and "" not in stripped[3:-4]:
+ return None
+
+ return formatted
+
+
+def _build_matrix_text_content(text: str) -> dict[str, str]:
+ """Build Matrix m.text payload with plaintext fallback and optional HTML."""
+ content: dict[str, str] = {"msgtype": "m.text", "body": text}
+ formatted_html = _render_markdown_html(text)
+ if not formatted_html:
+ return content
+
+ content["format"] = MATRIX_HTML_FORMAT
+ content["formatted_body"] = formatted_html
+ return content
class _NioLoguruHandler(logging.Handler):
@@ -141,7 +183,7 @@ class MatrixChannel(BaseChannel):
await self.client.room_send(
room_id=msg.chat_id,
message_type="m.room.message",
- content={"msgtype": "m.text", "body": msg.content},
+ content=_build_matrix_text_content(msg.content),
ignore_unverified_devices=True,
)
finally:
diff --git a/pyproject.toml b/pyproject.toml
index 18bbe70..82b37a3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,7 @@ dependencies = [
"mcp>=1.26.0,<2.0.0",
"json-repair>=0.57.0,<1.0.0",
"matrix-nio[e2e]>=0.25.2",
+ "mistune>=3.0.0",
]
[project.optional-dependencies]
From 3200135f4b849a9326087de6e151e5e0dd086d9a Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 15:02:29 +0100
Subject: [PATCH 243/506] test(matrix): cover formatted body and markdown
fallback
---
tests/test_matrix_channel.py | 61 +++++++++++++++++++++++++++++++++++-
1 file changed, 60 insertions(+), 1 deletion(-)
diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py
index b2accbb..e3bea9e 100644
--- a/tests/test_matrix_channel.py
+++ b/tests/test_matrix_channel.py
@@ -2,9 +2,14 @@ from types import SimpleNamespace
import pytest
+import nanobot.channels.matrix as matrix_module
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
-from nanobot.channels.matrix import TYPING_NOTICE_TIMEOUT_MS, MatrixChannel
+from nanobot.channels.matrix import (
+ MATRIX_HTML_FORMAT,
+ TYPING_NOTICE_TIMEOUT_MS,
+ MatrixChannel,
+)
from nanobot.config.schema import MatrixConfig
@@ -241,6 +246,7 @@ async def test_send_clears_typing_after_send() -> None:
)
assert len(client.room_send_calls) == 1
+ assert client.room_send_calls[0]["content"] == {"msgtype": "m.text", "body": "Hi"}
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
@@ -257,3 +263,56 @@ async def test_send_clears_typing_when_send_fails() -> None:
)
assert client.typing_calls[-1] == ("!room:matrix.org", False, TYPING_NOTICE_TIMEOUT_MS)
+
+
+@pytest.mark.asyncio
+async def test_send_adds_formatted_body_for_markdown() -> None:
+ channel = MatrixChannel(_make_config(), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ markdown_text = "# Headline\n\n- [x] done\n\n| A | B |\n| - | - |\n| 1 | 2 |"
+ await channel.send(
+ OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text)
+ )
+
+ content = client.room_send_calls[0]["content"]
+ assert content["msgtype"] == "m.text"
+ assert content["body"] == markdown_text
+ assert content["format"] == MATRIX_HTML_FORMAT
+ assert "Headline
" in str(content["formatted_body"])
+ assert "" in str(content["formatted_body"])
+ assert "task-list-item-checkbox" in str(content["formatted_body"])
+
+
+@pytest.mark.asyncio
+async def test_send_falls_back_to_plaintext_when_markdown_render_fails(monkeypatch) -> None:
+ channel = MatrixChannel(_make_config(), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ def _raise(text: str) -> str:
+ raise RuntimeError("boom")
+
+ monkeypatch.setattr(matrix_module, "MATRIX_MARKDOWN", _raise)
+ markdown_text = "# Headline"
+ await channel.send(
+ OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text)
+ )
+
+ content = client.room_send_calls[0]["content"]
+ assert content == {"msgtype": "m.text", "body": markdown_text}
+
+
+@pytest.mark.asyncio
+async def test_send_keeps_plaintext_only_for_plain_text() -> None:
+ channel = MatrixChannel(_make_config(), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ text = "just a normal sentence without markdown markers"
+ await channel.send(
+ OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=text)
+ )
+
+ assert client.room_send_calls[0]["content"] == {"msgtype": "m.text", "body": text}
From fa2049fc602865868f3e5b06fea0d32d02aeeb4d Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 15:30:39 +0100
Subject: [PATCH 244/506] feat(matrix): add group policy and strict mention
gating
---
nanobot/channels/matrix.py | 53 ++++++++++++++++++++++++++++++++------
nanobot/config/schema.py | 5 ++++
2 files changed, 50 insertions(+), 8 deletions(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 0cf354c..fbe511b 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -266,18 +266,55 @@ class MatrixChannel(BaseChannel):
await self.client.join(room.room_id)
+ def _is_direct_room(self, room: MatrixRoom) -> bool:
+ """Return True if the room behaves like a DM (2 or fewer members)."""
+ member_count = getattr(room, "member_count", None)
+ return isinstance(member_count, int) and member_count <= 2
+
+ def _is_bot_mentioned_from_mx_mentions(self, event: RoomMessageText) -> bool:
+ """Resolve mentions strictly from Matrix-native m.mentions payload."""
+ source = getattr(event, "source", None)
+ if not isinstance(source, dict):
+ return False
+
+ content = source.get("content")
+ if not isinstance(content, dict):
+ return False
+
+ mentions = content.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: RoomMessageText) -> bool:
+ """Apply sender and room policy checks before processing Matrix messages."""
+ 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_from_mx_mentions(event)
+
+ return False
+
async def _on_message(self, room: MatrixRoom, event: RoomMessageText) -> None:
# Ignore self messages
if event.sender == self.config.user_id:
return
- if not self.is_allowed(event.sender):
- await self._handle_message(
- sender_id=event.sender,
- chat_id=room.room_id,
- content=event.body,
- metadata={"room": room.display_name},
- )
+ if not self._should_process_message(room, event):
return
await self._set_typing(room.room_id, True)
@@ -286,7 +323,7 @@ class MatrixChannel(BaseChannel):
sender_id=event.sender,
chat_id=room.room_id,
content=event.body,
- metadata={"room": room.display_name},
+ metadata={"room": getattr(room, "display_name", room.room_id)},
)
except Exception:
await self._set_typing(room.room_id, False)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index f8d251b..d442104 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -1,6 +1,8 @@
"""Configuration schema using Pydantic."""
from pathlib import Path
+from typing import Literal
+
from pydantic import BaseModel, Field, ConfigDict
from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings
@@ -73,6 +75,9 @@ class MatrixConfig(Base):
# Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
sync_stop_grace_seconds: int = 2
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):
From cc5cfe68477ce793b8f121cfe3de000b67530d8e Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 15:30:54 +0100
Subject: [PATCH 245/506] test(matrix): cover mention policy and sender
filtering
---
tests/test_matrix_channel.py | 145 ++++++++++++++++++++++++++++++++++-
1 file changed, 142 insertions(+), 3 deletions(-)
diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py
index e3bea9e..cc834c3 100644
--- a/tests/test_matrix_channel.py
+++ b/tests/test_matrix_channel.py
@@ -197,7 +197,7 @@ async def test_on_message_sets_typing_for_allowed_sender() -> None:
channel._handle_message = _fake_handle_message # type: ignore[method-assign]
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
- event = SimpleNamespace(sender="@alice:matrix.org", body="Hello")
+ event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={})
await channel._on_message(room, event)
@@ -214,7 +214,7 @@ async def test_on_message_skips_typing_for_self_message() -> None:
channel.client = client
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
- event = SimpleNamespace(sender="@bot:matrix.org", body="Hello")
+ event = SimpleNamespace(sender="@bot:matrix.org", body="Hello", source={})
await channel._on_message(room, event)
@@ -227,14 +227,153 @@ async def test_on_message_skips_typing_for_denied_sender() -> None:
client = _FakeAsyncClient("", "", "", None)
channel.client = client
+ handled: list[str] = []
+
+ async def _fake_handle_message(**kwargs) -> None:
+ handled.append(kwargs["sender_id"])
+
+ channel._handle_message = _fake_handle_message # type: ignore[method-assign]
+
room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room")
- event = SimpleNamespace(sender="@alice:matrix.org", body="Hello")
+ event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={})
await channel._on_message(room, event)
+ assert handled == []
assert client.typing_calls == []
+@pytest.mark.asyncio
+async def test_on_message_mention_policy_requires_mx_mentions() -> None:
+ channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ handled: list[str] = []
+
+ async def _fake_handle_message(**kwargs) -> None:
+ handled.append(kwargs["sender_id"])
+
+ channel._handle_message = _fake_handle_message # type: ignore[method-assign]
+
+ room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
+ event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}})
+
+ await channel._on_message(room, event)
+
+ assert handled == []
+ assert client.typing_calls == []
+
+
+@pytest.mark.asyncio
+async def test_on_message_mention_policy_accepts_bot_user_mentions() -> None:
+ channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ handled: list[str] = []
+
+ async def _fake_handle_message(**kwargs) -> None:
+ handled.append(kwargs["sender_id"])
+
+ channel._handle_message = _fake_handle_message # type: ignore[method-assign]
+
+ room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
+ event = SimpleNamespace(
+ sender="@alice:matrix.org",
+ body="Hello",
+ source={"content": {"m.mentions": {"user_ids": ["@bot:matrix.org"]}}},
+ )
+
+ await channel._on_message(room, event)
+
+ assert handled == ["@alice:matrix.org"]
+ assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]
+
+
+@pytest.mark.asyncio
+async def test_on_message_mention_policy_allows_direct_room_without_mentions() -> None:
+ channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ handled: list[str] = []
+
+ async def _fake_handle_message(**kwargs) -> None:
+ handled.append(kwargs["sender_id"])
+
+ channel._handle_message = _fake_handle_message # type: ignore[method-assign]
+
+ room = SimpleNamespace(room_id="!dm:matrix.org", display_name="DM", member_count=2)
+ event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}})
+
+ await channel._on_message(room, event)
+
+ assert handled == ["@alice:matrix.org"]
+ assert client.typing_calls == [("!dm:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]
+
+
+@pytest.mark.asyncio
+async def test_on_message_allowlist_policy_requires_room_id() -> None:
+ channel = MatrixChannel(
+ _make_config(group_policy="allowlist", group_allow_from=["!allowed:matrix.org"]),
+ MessageBus(),
+ )
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ handled: list[str] = []
+
+ async def _fake_handle_message(**kwargs) -> None:
+ handled.append(kwargs["chat_id"])
+
+ channel._handle_message = _fake_handle_message # type: ignore[method-assign]
+
+ denied_room = SimpleNamespace(room_id="!denied:matrix.org", display_name="Denied", member_count=3)
+ event = SimpleNamespace(sender="@alice:matrix.org", body="Hello", source={"content": {}})
+ await channel._on_message(denied_room, event)
+
+ allowed_room = SimpleNamespace(
+ room_id="!allowed:matrix.org",
+ display_name="Allowed",
+ member_count=3,
+ )
+ await channel._on_message(allowed_room, event)
+
+ assert handled == ["!allowed:matrix.org"]
+ assert client.typing_calls == [("!allowed:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]
+
+
+@pytest.mark.asyncio
+async def test_on_message_room_mention_requires_opt_in() -> None:
+ channel = MatrixChannel(_make_config(group_policy="mention"), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ handled: list[str] = []
+
+ async def _fake_handle_message(**kwargs) -> None:
+ handled.append(kwargs["sender_id"])
+
+ channel._handle_message = _fake_handle_message # type: ignore[method-assign]
+
+ room = SimpleNamespace(room_id="!room:matrix.org", display_name="Test room", member_count=3)
+ room_mention_event = SimpleNamespace(
+ sender="@alice:matrix.org",
+ body="Hello everyone",
+ source={"content": {"m.mentions": {"room": True}}},
+ )
+
+ await channel._on_message(room, room_mention_event)
+ assert handled == []
+ assert client.typing_calls == []
+
+ channel.config.allow_room_mentions = True
+ await channel._on_message(room, room_mention_event)
+ assert handled == ["@alice:matrix.org"]
+ assert client.typing_calls == [("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)]
+
+
@pytest.mark.asyncio
async def test_send_clears_typing_after_send() -> None:
channel = MatrixChannel(_make_config(), MessageBus())
From 9b14869cb10daa09a65d0f4e321dc63cbd812363 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 15:56:21 +0100
Subject: [PATCH 246/506] feat(matrix): support inline markdown html for url
and super/subscript
---
nanobot/channels/matrix.py | 16 +++++++++++++---
tests/test_matrix_channel.py | 20 ++++++++++++++++++++
2 files changed, 33 insertions(+), 3 deletions(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index fbe511b..61113ac 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -24,9 +24,16 @@ LOGGING_STACK_BASE_DEPTH = 2
TYPING_NOTICE_TIMEOUT_MS = 30_000
MATRIX_HTML_FORMAT = "org.matrix.custom.html"
+# Keep plugin output aligned with Matrix recommended HTML tags:
+# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes
+# - table/strikethrough/task_lists are already used in replies.
+# - url, superscript, and subscript map to common tags (, , )
+# that Matrix clients (e.g. Element/FluffyChat) can render consistently.
+# We intentionally avoid plugins that emit less-portable tags to keep output
+# predictable across clients.
MATRIX_MARKDOWN = create_markdown(
escape=True,
- plugins=["table", "strikethrough", "task_lists"],
+ plugins=["table", "strikethrough", "task_lists", "url", "superscript", "subscript"],
)
@@ -47,8 +54,11 @@ def _render_markdown_html(text: str) -> str | None:
# Skip formatted_body for plain output (...
) to keep payload minimal.
stripped = formatted.strip()
- if stripped.startswith("") and stripped.endswith("
") and "" not in stripped[3:-4]:
- return None
+ if stripped.startswith("
") and stripped.endswith("
"):
+ paragraph_inner = stripped[3:-4]
+ # Keep plaintext-only paragraphs minimal, but preserve inline markup/links.
+ if "<" not in paragraph_inner and ">" not in paragraph_inner:
+ return None
return formatted
diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py
index cc834c3..2e3dad2 100644
--- a/tests/test_matrix_channel.py
+++ b/tests/test_matrix_channel.py
@@ -424,6 +424,26 @@ async def test_send_adds_formatted_body_for_markdown() -> None:
assert "task-list-item-checkbox" in str(content["formatted_body"])
+@pytest.mark.asyncio
+async def test_send_adds_formatted_body_for_inline_url_superscript_subscript() -> None:
+ channel = MatrixChannel(_make_config(), MessageBus())
+ client = _FakeAsyncClient("", "", "", None)
+ channel.client = client
+
+ markdown_text = "Visit https://example.com and x^2^ plus H~2~O."
+ await channel.send(
+ OutboundMessage(channel="matrix", chat_id="!room:matrix.org", content=markdown_text)
+ )
+
+ content = client.room_send_calls[0]["content"]
+ assert content["msgtype"] == "m.text"
+ assert content["body"] == markdown_text
+ assert content["format"] == MATRIX_HTML_FORMAT
+ assert '' in str(content["formatted_body"])
+ assert "2" in str(content["formatted_body"])
+ assert "2" in str(content["formatted_body"])
+
+
@pytest.mark.asyncio
async def test_send_falls_back_to_plaintext_when_markdown_render_fails(monkeypatch) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
From 6be7368a38e8c1fe9bafc2b2517453040ce83ac6 Mon Sep 17 00:00:00 2001
From: Alexander Minges
Date: Tue, 10 Feb 2026 16:18:47 +0100
Subject: [PATCH 247/506] fix(matrix): sanitize formatted html with nh3
---
nanobot/channels/matrix.py | 92 ++++++++++++++++++++++++++++++++++--
pyproject.toml | 3 +-
tests/test_matrix_channel.py | 48 ++++++++++++++++++-
3 files changed, 137 insertions(+), 6 deletions(-)
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 61113ac..8240b51 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -2,6 +2,7 @@ import asyncio
import logging
from typing import Any
+import nh3
from loguru import logger
from mistune import create_markdown
from nio import (
@@ -26,21 +27,106 @@ MATRIX_HTML_FORMAT = "org.matrix.custom.html"
# Keep plugin output aligned with Matrix recommended HTML tags:
# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes
-# - table/strikethrough/task_lists are already used in replies.
+# - table/strikethrough are already used in replies.
# - url, superscript, and subscript map to common tags (, , )
# that Matrix clients (e.g. Element/FluffyChat) can render consistently.
# We intentionally avoid plugins that emit less-portable tags to keep output
# predictable across clients.
MATRIX_MARKDOWN = create_markdown(
escape=True,
- plugins=["table", "strikethrough", "task_lists", "url", "superscript", "subscript"],
+ plugins=["table", "strikethrough", "url", "superscript", "subscript"],
+)
+
+# Sanitizer policy rationale:
+# - Baseline follows Matrix formatted message guidance:
+# https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes
+# - We intentionally use a tighter subset than the full spec to keep behavior
+# predictable across clients and reduce risk from LLM-generated content.
+# - URLs are restricted to common safe schemes for links, and image sources are
+# additionally constrained to mxc:// for Matrix-native media handling.
+# - Spec items intentionally NOT enabled yet:
+# - href schemes ftp/magnet (we keep link schemes smaller for now).
+# - a[target] (clients already control link-opening behavior).
+# - span[data-mx-bg-color|data-mx-color|data-mx-spoiler|data-mx-maths]
+# - div[data-mx-maths]
+# These can be added later when we explicitly support those Matrix features.
+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":
+ lower_value = value.lower()
+ if lower_value.startswith(("https://", "http://", "matrix:", "mailto:")):
+ return value
+ return None
+
+ if tag == "img" and attr == "src":
+ return value if value.lower().startswith("mxc://") else None
+
+ if tag == "code" and attr == "class":
+ classes = [
+ cls
+ for cls in value.split()
+ if cls.startswith("language-") and not cls.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 HTML for Matrix formatted messages."""
try:
- formatted = MATRIX_MARKDOWN(text).strip()
+ rendered = MATRIX_MARKDOWN(text)
+ formatted = MATRIX_HTML_CLEANER.clean(rendered).strip()
except Exception as e:
logger.debug(
"Matrix markdown rendering failed ({}): {}",
diff --git a/pyproject.toml b/pyproject.toml
index 82b37a3..12a1ee8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,7 +43,8 @@ dependencies = [
"mcp>=1.26.0,<2.0.0",
"json-repair>=0.57.0,<1.0.0",
"matrix-nio[e2e]>=0.25.2",
- "mistune>=3.0.0",
+ "mistune>=3.0.0,<4.0.0",
+ "nh3>=0.2.17,<1.0.0",
]
[project.optional-dependencies]
diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py
index 2e3dad2..616b0bc 100644
--- a/tests/test_matrix_channel.py
+++ b/tests/test_matrix_channel.py
@@ -421,7 +421,7 @@ async def test_send_adds_formatted_body_for_markdown() -> None:
assert content["format"] == MATRIX_HTML_FORMAT
assert "Headline
" in str(content["formatted_body"])
assert "