diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a230a78 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/ai_bot.py b/ai_bot.py index 659ce16..c1367c4 100644 --- a/ai_bot.py +++ b/ai_bot.py @@ -1,1424 +1,10 @@ #!/usr/bin/env python3 """Orac: a terse AI assistant on the MeshCore LoRa mesh network. -Monitors all decryptable MeshCore channels for !askai, !ai, !claude, or !orac -triggers. Also accepts direct messages (DMs) — no trigger needed for DMs. -Advertises itself on the mesh and persists state across restarts. -""" -import collections -import csv -import hashlib -import hmac -import json -import os -import random -import struct -import sys -import time - -import anthropic -import serial -from cobs import cobs -from Crypto.Cipher import AES -from nacl.bindings import crypto_scalarmult -from nacl.signing import SigningKey, VerifyKey -from pathlib import Path - -import donglora as dl - - -# ── Constants ───────────────────────────────────────────────────── - -BOT_NAME = "Orac" -MAX_GRP_TEXT = 163 # max bytes for "SenderName: message" in a GRP_TXT -MAX_RESPONSE_CHARS = MAX_GRP_TEXT - len(BOT_NAME) - 2 # subtract "Orac: " -# DM plaintext: timestamp(4) + txt_type_attempt(1) + text + padding to 16 -# Max payload 184 - dst(1) - src(1) - mac(2) = 180 bytes ciphertext -# 180 bytes ciphertext / 16 = 11 blocks → 176 bytes plaintext max -# 176 - 5 (header) = 171 bytes text, minus null terminator padding -MAX_DM_TEXT = 170 - -TRIGGERS = ("!askai", "!ai", "!claude", "!orac") -AT_MENTIONS = (f"@{BOT_NAME}".lower(), f"@[{BOT_NAME}]".lower()) # "@orac" and "@[Orac]" - -CHANNEL_HISTORY_SIZE = 20 # messages per channel -DM_HISTORY_SIZE = 20 # messages per DM peer - -RATE_LIMIT_PER_SENDER = 5 # for channel messages -RATE_LIMIT_GLOBAL = 2 # between any two responses -RATE_LIMIT_DM = 2 # for DMs (more interactive) - -ADVERT_INTERVAL = 7200 # 2 hours in seconds - -DATA_DIR = Path.home() / ".donglora" -KEY_FILE = DATA_DIR / "orac_key.bin" -STATE_FILE = DATA_DIR / "orac_state.json" - -RADIO_CONFIG = { - "freq_hz": 910_525_000, - "bw": 6, # 62.5 kHz - "sf": 7, - "cr": 5, # CR 4/5 - "sync_word": 0x3444, - "tx_power_dbm": -128, # TX_POWER_MAX -} - -SYSTEM_PROMPT = f"""\ -You are Orac, a terse AI assistant on a MeshCore LoRa radio mesh network. - -ABSOLUTE HARD LIMIT: Your ENTIRE response must be {{max_chars}} characters or fewer. -This is a physical constraint of the radio protocol. Every character beyond this limit -is permanently lost and never transmitted. There is NO exception. - -Stay well under {{max_chars}}. Aim for 120 or fewer. NEVER show a character count or HTML tags in your reply. - -You can see recent messages for context. When someone asks you a question, they may -be referring to the ongoing conversation — use the chat history to understand what they mean. -For example, "what do you think?" refers to whatever was just discussed. "tell me more" means -elaborate on the recent topic. Read the room. - -Rules: -- One sentence or short phrase. Never more. -- Emoji OK but don't overdo it. No markdown, no bullet points, no formatting. -- Use common abbreviations freely (e.g., w/, b/c, approx, etc). -- Skip all pleasantries, greetings, and filler. -- You have a dry, sardonic wit. Be helpful but never wordy. -- If the question requires a long answer, give the most important point only. -- NEVER repeat a previous answer. Always find a fresh angle, new phrasing, or different take. -- Be creative and unpredictable. Surprise the reader.\ +Entry point — delegates to the orac package. """ -# ── ANSI helpers ────────────────────────────────────────────────── - -RST = "\033[0m" -DIM = "\033[2m" -RED = "\033[31m" -GRN = "\033[32m" -YEL = "\033[33m" -CYN = "\033[36m" -BCYN = "\033[1;36m" -BGRN = "\033[1;32m" -BMAG = "\033[1;35m" -BWHT = "\033[1;37m" - - -# ── Persistent state ───────────────────────────────────────────── - -_state: dict = { - "channel_history": {}, # channel_name → list of messages - "dm_history": {}, # peer_pubkey_hex → list of messages - "known_nodes": {}, # pubkey_hex → {"name": str, "seen": float} -} - - -def _load_state(): - global _state - if STATE_FILE.is_file(): - try: - with open(STATE_FILE) as f: - loaded = json.load(f) - # Merge with defaults - for key in _state: - if key in loaded: - _state[key] = loaded[key] - print(f" {DIM}Loaded state from {STATE_FILE}{RST}") - except Exception as e: - print(f" {YEL}Failed to load state: {e}{RST}") - - -def _save_state(): - try: - DATA_DIR.mkdir(parents=True, exist_ok=True) - tmp = STATE_FILE.with_suffix(".tmp") - with open(tmp, "w") as f: - json.dump(_state, f, indent=2) - tmp.rename(STATE_FILE) - except Exception as e: - print(f" {RED}Failed to save state: {e}{RST}") - - -# ── Identity / Keypair ─────────────────────────────────────────── - -_signing_key: SigningKey | None = None -_verify_key: VerifyKey | None = None -_pubkey_bytes: bytes = b"" - - -def _init_identity(): - """Load or generate Ed25519 keypair. Persisted to disk.""" - global _signing_key, _verify_key, _pubkey_bytes - - DATA_DIR.mkdir(parents=True, exist_ok=True) - - if KEY_FILE.is_file(): - seed = KEY_FILE.read_bytes() - if len(seed) == 32: - _signing_key = SigningKey(seed) - print(f" {DIM}Loaded keypair from {KEY_FILE}{RST}") - else: - print(f" {YEL}Invalid key file, regenerating{RST}") - _signing_key = SigningKey.generate() - KEY_FILE.write_bytes(bytes(_signing_key)) - else: - _signing_key = SigningKey.generate() - KEY_FILE.write_bytes(bytes(_signing_key)) - print(f" {BWHT}Generated new keypair → {KEY_FILE}{RST}") - - _verify_key = _signing_key.verify_key - _pubkey_bytes = bytes(_verify_key) - print(f" {DIM}Pubkey: {_pubkey_bytes.hex()}{RST}") - print(f" {DIM}Node hash: 0x{_pubkey_bytes[0]:02x}{RST}") - - -def _my_hash() -> int: - """Our 1-byte node hash (first byte of pubkey).""" - return _pubkey_bytes[0] - - -# ── MeshCore channel crypto ────────────────────────────────────── - - -def _channel_secret_from_hashtag(name: str) -> bytes: - h = hashlib.sha256(name.encode()).digest() - return h[:16] + b"\x00" * 16 - - -def _channel_hash(secret: bytes) -> int: - key_len = 16 if secret[16:] == b"\x00" * 16 else 32 - return hashlib.sha256(secret[:key_len]).digest()[0] - - -def _grp_verify_and_decrypt( - secret: bytes, mac_bytes: bytes, ciphertext: bytes -) -> bytes | None: - key_len = 32 # HMAC always uses full 32-byte secret (spec Section 14) - computed = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2] - if computed != mac_bytes: - return None - cipher = AES.new(secret[:16], AES.MODE_ECB) - plaintext = b"" - for i in range(0, len(ciphertext), 16): - plaintext += cipher.decrypt(ciphertext[i : i + 16]) - return plaintext - - -def _parse_grp_plaintext(plaintext: bytes) -> tuple[int, str] | None: - if len(plaintext) < 5: - return None - timestamp = struct.unpack_from(" bytes: - """Compute X25519 shared secret from our Ed25519 key and peer's Ed25519 pubkey.""" - my_x25519 = bytes(_signing_key.to_curve25519_private_key()) - peer_verify = VerifyKey(peer_ed25519_pub) - peer_x25519 = bytes(peer_verify.to_curve25519_public_key()) - return crypto_scalarmult(my_x25519, peer_x25519) - - -def _peer_encrypt(shared_secret: bytes, text: str) -> bytes: - """Encrypt a DM plaintext. Returns mac(2) + ciphertext.""" - plaintext = struct.pack(" bytes | None: - """Verify MAC then decrypt a peer message. Returns plaintext or None.""" - computed = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2] - if computed != mac_bytes: - return None - cipher = AES.new(shared_secret[:16], AES.MODE_ECB) - plaintext = b"" - for i in range(0, len(ciphertext), 16): - plaintext += cipher.decrypt(ciphertext[i : i + 16]) - return plaintext - - -def _parse_peer_plaintext(plaintext: bytes) -> str | None: - """Parse decrypted peer message: timestamp(4) + txt_type_attempt(1) + text.""" - if len(plaintext) < 5: - return None - text = plaintext[5:].split(b"\x00", 1)[0] - return text.decode("utf-8", errors="replace") if text else None - - -# ── Node registry ──────────────────────────────────────────────── - -# pubkey_hex → {"name": str, "seen": float} -# Stored in _state["known_nodes"], persisted to disk - -ADVERT_MAX_AGE = 43200 # 12 hours — used for display, NOT for key expiry - -# Pending DMs from unknown senders — queued until we learn their pubkey via ADVERT -# src_hash → list of (raw_payload, monotonic_timestamp) -_pending_dms: dict[int, list[tuple[bytes, float]]] = {} -PENDING_DM_TTL = 300 # 5 minutes — discard stale pending DMs - -# ── Route table ───────────────────────────────────────────────── -# Learned return paths from incoming packets. -# src_hash (int) → (reversed_path_hops: list[bytes], hash_size: int) -_route_table: dict[int, tuple[list[bytes], int]] = {} - - -def _learn_route(src_hash: int, path_hops: list[bytes], hash_size: int): - """Learn a return route from an incoming packet's path. - - path_hops is ordered closest-relay-first as received. - We reverse it so our outgoing path goes through the same relays - back toward the sender. Always updates the route, but prefers - larger hash sizes (3 > 2 > 1) for better addressing precision. - """ - existing = _route_table.get(src_hash) - if existing is not None: - _, existing_hs = existing - if hash_size < existing_hs: - return # Don't downgrade hash size - _route_table[src_hash] = (list(reversed(path_hops)), hash_size) - - -def _get_route(dest_hash: int) -> tuple[list[bytes], int] | None: - """Get a learned return route for a destination, or None if unknown.""" - return _route_table.get(dest_hash) - - -_RT_NAMES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"} - - -def _route_name(route_type: int) -> str: - return _RT_NAMES.get(route_type, f"rt{route_type}") - - -def _register_node(pubkey: bytes, name: str) -> bool: - """Register a node. Returns True if this is a NEW node (not just an update).""" - pk_hex = pubkey.hex() - is_new = pk_hex not in _state["known_nodes"] - _state["known_nodes"][pk_hex] = {"name": name, "seen": time.time()} - _save_state() - return is_new - - -def _lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]: - """Find all known nodes whose pubkey first byte matches. Returns [(pubkey, name)]. - - Never expires keys — once we learn a peer's pubkey, we can always decrypt - their DMs. Peers shouldn't need to re-advertise just to message the bot. - """ - results = [] - for pk_hex, info in _state["known_nodes"].items(): - pk_bytes = bytes.fromhex(pk_hex) - if pk_bytes[0] == hash_byte: - results.append((pk_bytes, info["name"])) - return results - - -def _node_name(pubkey_hex: str) -> str: - info = _state["known_nodes"].get(pubkey_hex) - return info["name"] if info else pubkey_hex[:8] - - -# ── Channel registry ───────────────────────────────────────────── - -_KNOWN_CHANNELS: dict[str, bytes] = {} -_CHANNEL_BY_HASH: dict[int, list[tuple[str, bytes]]] = {} - - -def _register_channel(name: str, secret: bytes): - _KNOWN_CHANNELS[name] = secret - h = _channel_hash(secret) - _CHANNEL_BY_HASH.setdefault(h, []).append((name, secret)) - - -_CHANNELS_CSV = Path(__file__).parent / "channels.csv" - - -def _init_channels(): - if _CHANNELS_CSV.is_file(): - with open(_CHANNELS_CSV, newline="") as f: - for row in csv.DictReader(f): - name = row["channel_name"] - is_hashtag = row["hashtag"].strip().lower() == "true" - key_hex = row["key_hex"].strip() - if is_hashtag: - secret = _channel_secret_from_hashtag(name) - else: - secret = bytes.fromhex(key_hex) + b"\x00" * 16 - _register_channel(name, secret) - else: - print(f" {RED}channels.csv not found at {_CHANNELS_CSV}{RST}") - - for name in ("#devtest", "#devtestdevtest", "#askai", "#orac", "#ai", "#aibot", "#aibots"): - _register_channel(name, _channel_secret_from_hashtag(name)) - - -_init_channels() - - -# ── GRP_TXT encrypt + transmit ─────────────────────────────────── - - -def _grp_encrypt(secret: bytes, sender: str, text: str) -> bytes: - ch = _channel_hash(secret) - plaintext = struct.pack(" bytes: - header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1 - path_len = bytes([0x40]) # 0 hops, 2-byte hash mode - return header + path_len + channel_payload - - -def _transmit_packet(ser: serial.Serial, packet: bytes, label: str = "Transmit"): - """Transmit a raw MeshCore packet.""" - try: - resp = send_cmd(ser, {"type": "Transmit", "payload": packet}, label=label) - if resp is None: - print(f" {RED}TX failed: no response from radio{RST}") - elif resp["type"] == "Error": - print(f" {RED}TX failed: {resp}{RST}") - except Exception as e: - print(f" {RED}TX exception: {e}{RST}") - - -def _grp_transmit(ser: serial.Serial, channel_name: str, sender: str, text: str): - secret = _KNOWN_CHANNELS.get(channel_name) - if secret is None: - print(f" {RED}TX failed: unknown channel {channel_name}{RST}") - return - payload = _grp_encrypt(secret, sender, text) - packet = _grp_build_packet(payload) - _transmit_packet(ser, packet) - - -# ── ADVERT construction + transmit ─────────────────────────────── - - -def _build_advert_packet() -> bytes: - """Build a MeshCore ADVERT packet for this node.""" - timestamp = struct.pack(" bytes: - """Build a packet using a learned route if available, otherwise flood.""" - route = _get_route(dest_hash) - if route is not None: - hops, hash_size = route - hash_size_code = hash_size - 1 - path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F) - path_data = b"".join(hops) - # direct route_type = 2 - header = bytes([(payload_type << 2) | 2]) - return header + bytes([path_len_byte]) + path_data + payload - else: - # flood route_type = 1 - header = bytes([(payload_type << 2) | 1]) - path_len = bytes([0x40]) # 0 hops, 2-byte hash mode - return header + path_len + payload - - -def _dm_transmit(ser: serial.Serial, peer_pubkey: bytes, text: str): - """Encrypt and transmit a TXT_MSG DM using learned route or flood.""" - # Simulate human typing delay (~40ms/char, clamped to 1–5s) - delay = min(max(len(text) * 0.04, 1.0), 5.0) - time.sleep(delay) - shared_secret = _ecdh_shared_secret(peer_pubkey) - mac_ct = _peer_encrypt(shared_secret, text) - - # Payload: dest_hash(1) + src_hash(1) + mac(2) + ciphertext - dm_payload = bytes([peer_pubkey[0], _my_hash()]) + mac_ct - - packet = _build_routed_packet(0x02, dm_payload, peer_pubkey[0]) - - peer_name = _node_name(peer_pubkey.hex()) - route = _get_route(peer_pubkey[0]) - route_str = "direct" if route is not None else "flood" - _transmit_packet(ser, packet, label=f"DM({route_str})\u2192{peer_name}") - - -# ── ACK transmit ───────────────────────────────────────────────── - - -def _compute_ack_hash(plaintext: bytes, sender_pubkey: bytes) -> bytes: - """Compute 4-byte ACK hash per MeshCore BaseChatMesh.cpp. - - Hash = SHA-256(frag1 || sender_pubkey)[0:4] - frag1 = timestamp(4) + txt_type_attempt(1) + text (no null terminator) - sender_pubkey = sender's Ed25519 public key (32 bytes) - """ - text_bytes = plaintext[5:] - null_pos = text_bytes.find(b"\x00") - text_len = null_pos if null_pos >= 0 else len(text_bytes) - frag1 = plaintext[: 5 + text_len] - return hashlib.sha256(frag1 + sender_pubkey).digest()[:4] - - -def _send_ack(ser: serial.Serial, ack_crc: bytes, dest_hash: int): - """Send an ACK using learned route or flood, with a brief delay for the DM flood to clear.""" - time.sleep(0.5) - packet = _build_routed_packet(0x03, ack_crc, dest_hash) - _transmit_packet(ser, packet, label="ACK") - - -def _send_path_return(ser: serial.Serial, peer_pubkey: bytes, path_hops: list[bytes], hash_size: int): - """Send a PATH return (0x08) so the sender can learn a direct route to us. - - Per spec Section 9: inner plaintext = path_len(1) + path + extra_type(1) + extra. - The path is reversed (our incoming path becomes the sender's outgoing path). - """ - reversed_hops = list(reversed(path_hops)) - hash_size_code = hash_size - 1 - path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F) - path_data = b"".join(reversed_hops) - - # Inner plaintext: path_len + path + 0xFF (dummy extra) + 4 random bytes - inner = bytes([path_len_byte]) + path_data + b"\xff" + os.urandom(4) - pad_len = (16 - len(inner) % 16) % 16 - inner += b"\x00" * pad_len - - shared_secret = _ecdh_shared_secret(peer_pubkey) - cipher = AES.new(shared_secret[:16], AES.MODE_ECB) - ciphertext = b"" - for i in range(0, len(inner), 16): - ciphertext += cipher.encrypt(inner[i : i + 16]) - mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2] - - # Outer: dest_hash + src_hash + mac + ciphertext - path_payload = bytes([peer_pubkey[0], _my_hash()]) + mac + ciphertext - packet = _build_routed_packet(0x08, path_payload, peer_pubkey[0]) - _transmit_packet(ser, packet, label="PATH") - - -# ── Serial / USB ───────────────────────────────────────────────── - - -def open_connection(port: str | None = None): - """Open a DongLoRa connection (mux auto-detected, falls back to USB).""" - return dl.connect(port=port, timeout=2) - - -# ── COBS framing ───────────────────────────────────────────────── - - -def cobs_frame(payload: bytes) -> bytes: - return cobs.encode(payload) + b"\x00" - - -def read_frame(ser: serial.Serial) -> bytes | None: - buf = b"" - while True: - b = ser.read(1) - if not b: - return None - if b == b"\x00": - break - buf += b - if not buf: - return None - try: - return cobs.decode(buf) - except cobs.DecodeError: - return None - - -# ── Command encoding / response decoding ───────────────────────── - - -def encode_radio_config(cfg: dict) -> bytes: - return struct.pack( - " bytes: - kind = cmd["type"] - tags = { - "Ping": 0, "GetConfig": 1, "SetConfig": 2, "StartRx": 3, - "StopRx": 4, "Transmit": 5, "DisplayOn": 6, "DisplayOff": 7, - } - out = bytes([tags[kind]]) - if kind == "SetConfig": - out += encode_radio_config(cmd["config"]) - elif kind == "Transmit": - config = cmd.get("config") - if config is None: - out += b"\x00" - else: - out += b"\x01" + encode_radio_config(config) - payload = cmd["payload"] - out += struct.pack(" dict: - if not data: - return {"type": "Empty"} - tag = data[0] - rest = data[1:] - if tag == 0: - return {"type": "Pong"} - elif tag == 1: - return {"type": "Config", "raw": rest.hex()} - elif tag == 2: - rssi = struct.unpack_from(" dict | None: - payload = encode_command(cmd) - frame = cobs_frame(payload) - if not quiet: - print(f" {DIM}>>>{RST} {label}") - ser.write(frame) - ser.flush() - for _ in range(50): - resp_data = read_frame(ser) - if resp_data is None: - if not quiet: - print(f" {DIM}<<<{RST} {YEL}timeout{RST}") - return None - resp = decode_response(resp_data) - if resp["type"] == "RxPacket": - continue - if not quiet: - t = resp["type"] - print(f" {DIM}<<<{RST} {t}") - return resp - if not quiet: - print(f" {DIM}<<<{RST} {YEL}timeout (RxPacket flood){RST}") - return None - - -# ── Claude API ─────────────────────────────────────────────────── - -_client: anthropic.Anthropic | None = None - - -def _get_client() -> anthropic.Anthropic: - global _client - if _client is None: - _client = anthropic.Anthropic() - return _client - - -def _shorten_with_claude(text: str, max_chars: int) -> str: - try: - resp = _get_client().messages.create( - model="claude-sonnet-4-6", - max_tokens=256, - messages=[ - { - "role": "user", - "content": f"Shorten this to {max_chars} characters: {text}", - } - ], - ) - shortened = resp.content[0].text.strip() - except Exception as e: - print(f" {RED}Shorten API error: {e}{RST}") - shortened = text - encoded = shortened.encode("utf-8") - if len(encoded) > max_chars: - while len(shortened.encode("utf-8")) > max_chars - 1: - shortened = shortened[:-1] - shortened += "…" - return shortened - - -def _extract_text(resp) -> str: - text = "" - for block in resp.content: - if block.type == "text" and block.text.strip(): - text = block.text.strip() - return text - - -def call_claude(query: str, sender: str, history: list[str], max_chars: int, context_label: str) -> str | None: - """Send a query to Claude with conversation context. Returns response text or None.""" - try: - nonce = random.randint(1000, 9999) - now = time.strftime("%Y-%m-%d %H:%M %Z") - prompt = SYSTEM_PROMPT.replace("{max_chars}", str(max_chars)) + f"\n\nCurrent date/time: {now}" - - if history: - context = "\n".join(history) - user_content = ( - f"[seed:{nonce}]\n" - f"Recent messages in {context_label}:\n{context}\n\n" - f"{sender} says: {query}" - ) - else: - user_content = f"[seed:{nonce}] {sender} says: {query}" - - messages = [{"role": "user", "content": user_content}] - tools = [{"type": "web_search_20260209", "name": "web_search"}] - - text = "" - for round_num in range(3): - resp = _get_client().messages.create( - model="claude-sonnet-4-6", - max_tokens=4096, - temperature=1.0, - system=prompt, - tools=tools, - messages=messages, - ) - - text = _extract_text(resp) - if text: - break - - if resp.stop_reason != "tool_use": - block_types = [b.type for b in resp.content] - print(f" {YEL}No text, stop={resp.stop_reason}, blocks={block_types}{RST}") - break - - print(f" {DIM}(web search round {round_num + 1})...{RST}") - messages.append({"role": "assistant", "content": resp.content}) - tool_results = [] - for block in resp.content: - if hasattr(block, "id") and block.type == "server_tool_use": - tool_results.append( - {"type": "tool_result", "tool_use_id": block.id, "content": ""} - ) - if tool_results: - messages.append({"role": "user", "content": tool_results}) - else: - break - - if not text: - print(f" {YEL}No text after tool loop{RST}") - return None - except Exception as e: - print(f" {RED}Claude API error: {e}{RST}") - return None - - if len(text.encode("utf-8")) > max_chars: - print(f" {YEL}Response too long ({len(text.encode('utf-8'))}B), shortening...{RST}") - text = _shorten_with_claude(text, max_chars) - - return text - - -def _rate_limit_message() -> str: - try: - nonce = random.randint(1000, 9999) - resp = _get_client().messages.create( - model="claude-sonnet-4-6", - max_tokens=100, - temperature=1.0, - system=( - f"You are Orac, a sardonic AI on a radio mesh network. " - f"Generate a single short message ({MAX_RESPONSE_CHARS} chars max, ASCII only) " - f"telling someone to slow down and try again in a few seconds. " - f"Be witty, varied, and in-character. No quotes around the message." - ), - messages=[ - {"role": "user", "content": f"[seed:{nonce}] Rate limit hit."} - ], - ) - text = resp.content[0].text.strip().strip('"\'') - if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text: - return text - except Exception: - pass - return "Patience. Try again in a moment." - - -# ── Trigger detection ──────────────────────────────────────────── - - -def _extract_trigger_query(text: str) -> str | None: - """Check for !triggers and @mentions. Returns the query (everything except the trigger) or None.""" - lower = text.lower() - for trigger in (*TRIGGERS, *AT_MENTIONS): - idx = lower.find(trigger) - if idx != -1: - before = text[:idx] - after = text[idx + len(trigger) :] - query = (before + " " + after).strip() - if query: - return query - return None - - -# ── Packet parsing helpers ─────────────────────────────────────── - - -def _parse_header_and_path(packet: bytes) -> tuple[int, int, int, int, bytes, list[bytes], int] | None: - """Parse MeshCore header + path. - - Returns (payload_type, route_type, payload_ver, pos, payload, path_hops, hash_size) or None. - path_hops is a list of raw hash bytes for each hop (closest relay first). - """ - if len(packet) < 3: - return None - - header = packet[0] - route_type = header & 0x03 - payload_type = (header >> 2) & 0x0F - payload_ver = (header >> 6) & 0x03 - - if payload_ver != 0: - return None - - has_tc = route_type in (0, 3) - - for skip_tc in ([True, False] if has_tc else [False]): - pos = 5 if skip_tc else 1 - if pos >= len(packet): - continue - - path_len_byte = packet[pos] - pos += 1 - hash_size_code = path_len_byte >> 6 - if hash_size_code == 3: - continue - hash_size = hash_size_code + 1 - hop_count = path_len_byte & 0x3F - path_bytes = hop_count * hash_size - if path_bytes > 64 or pos + path_bytes > len(packet): - continue - - hops = [] - for i in range(hop_count): - hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size]) - pos += path_bytes - - payload = packet[pos:] - if payload and len(payload) <= 184: - return payload_type, route_type, payload_ver, pos, payload, hops, hash_size - - return None - - -# ── GRP_TXT interception ──────────────────────────────────────── - - -def _try_decrypt_grp(raw_payload: bytes) -> tuple[str, str] | None: - if len(raw_payload) < 19: - return None - ch = raw_payload[0] - mac_bytes = raw_payload[1:3] - ciphertext = raw_payload[3:] - - candidates = _CHANNEL_BY_HASH.get(ch, []) - for chan_name, secret in candidates: - plaintext = _grp_verify_and_decrypt(secret, mac_bytes, ciphertext) - if plaintext is not None: - parsed = _parse_grp_plaintext(plaintext) - if parsed: - _, text = parsed - return chan_name, text - return None - - -# ── DM interception ────────────────────────────────────────────── - - -def _try_decrypt_dm(raw_payload: bytes) -> tuple[bytes, str, str, bytes] | None: - """Try to decrypt a TXT_MSG DM addressed to us. - - Returns (sender_pubkey, sender_name, message_text, raw_plaintext) or None. - raw_plaintext is needed for ACK hash computation. - """ - if len(raw_payload) < 20: - return None - - dest_hash = raw_payload[0] - src_hash = raw_payload[1] - mac_bytes = raw_payload[2:4] - ciphertext = raw_payload[4:] - - print(f" {DIM}TXT_MSG dest=0x{dest_hash:02x} src=0x{src_hash:02x} me=0x{_my_hash():02x} [{len(ciphertext)}B]{RST}") - - if dest_hash != _my_hash(): - return None - - candidates = _lookup_node_by_hash(src_hash) - if not candidates: - print(f" {YEL}DM for us but no known node with hash 0x{src_hash:02x}{RST}") - return None - - for peer_pubkey, peer_name in candidates: - try: - shared_secret = _ecdh_shared_secret(peer_pubkey) - plaintext = _peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext) - if plaintext is not None: - text = _parse_peer_plaintext(plaintext) - if text: - return peer_pubkey, peer_name, text, plaintext - except Exception: - # ECDH failed — pubkey is likely corrupted (bad ADVERT). Evict it. - pk_hex = peer_pubkey.hex() - print(f" {YEL}Evicting bad key for {peer_name} ({pk_hex[:16]}...){RST}") - _state["known_nodes"].pop(pk_hex, None) - _save_state() - continue - - return None - - -RESP_SERVER_LOGIN_OK = 0x01 - - -def _try_decrypt_anon_req(raw_payload: bytes) -> tuple[bytes, str, bytes | None] | None: - """Try to decrypt an ANON_REQ (login). Returns (sender_pubkey, sender_name, plaintext_or_None) or None.""" - if len(raw_payload) < 51: - return None - - dest_hash = raw_payload[0] - sender_pubkey = raw_payload[1:33] - mac_bytes = raw_payload[33:35] - ciphertext = raw_payload[35:] - - print(f" {BCYN}ANON_REQ{RST} dest=0x{dest_hash:02x} sender={sender_pubkey.hex()[:16]}... [{len(ciphertext)}B]") - - if dest_hash != _my_hash(): - return None - - # Register sender — we now have their full pubkey - peer_name = _node_name(sender_pubkey.hex()) - is_new = _register_node(sender_pubkey, peer_name) - if is_new: - print(f" {BCYN}Registered new peer from ANON_REQ: {peer_name} (hash=0x{sender_pubkey[0]:02x}){RST}") - - try: - shared_secret = _ecdh_shared_secret(sender_pubkey) - plaintext = _peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext) - if plaintext is not None: - return sender_pubkey, peer_name, plaintext - else: - print(f" {YEL}ANON_REQ MAC mismatch{RST}") - except Exception as e: - print(f" {RED}ANON_REQ decrypt error: {e}{RST}") - - # Even if decryption failed, we registered the pubkey — return with no plaintext - return sender_pubkey, peer_name, None - - -def _send_login_response(ser: serial.Serial, peer_pubkey: bytes): - """Send a RESPONSE packet (login OK) back to the peer, like a room server.""" - shared_secret = _ecdh_shared_secret(peer_pubkey) - - # Response payload: timestamp(4) + RESP_SERVER_LOGIN_OK(1) + zero(1) + permissions(2) - reply = struct.pack(" tuple[bytes, str] | None: - """Decode and verify an ADVERT payload. Returns (pubkey, name) or None.""" - if len(raw_payload) < 100: - return None - - pubkey = raw_payload[0:32] - timestamp = raw_payload[32:36] - signature = raw_payload[36:100] - app_data = raw_payload[100:] - - # Verify Ed25519 signature to reject corrupted ADVERTs - try: - verify_key = VerifyKey(pubkey) - sign_msg = pubkey + timestamp + app_data - verify_key.verify(sign_msg, signature) - except Exception: - return None - - name = "" - if app_data: - flags = app_data[0] - pos = 1 - if flags & 0x10: # has location - pos += 8 - if flags & 0x20: # feat1 - pos += 2 - if flags & 0x40: # feat2 - pos += 2 - if flags & 0x80 and pos < len(app_data): # has name - name = app_data[pos:].decode("utf-8", errors="replace") - - return pubkey, name if name else pubkey.hex()[:8] - - -# ── Deduplication ──────────────────────────────────────────────── - -DEDUP_TTL = 120 -_seen_packets: dict[bytes, float] = {} - -# DM dedup on decrypted text (catches retries with different attempt counters) -DM_DEDUP_TTL = 60 -_seen_dm_texts: dict[str, float] = {} # "pubkey_hex:text" → monotonic timestamp - - -def _compute_packet_hash(payload_type: int, payload: bytes) -> bytes: - """Compute 8-byte packet hash per spec Section 16.""" - return hashlib.sha256(bytes([payload_type]) + payload).digest()[:8] - - -def _is_duplicate(payload_type: int, payload: bytes) -> bool: - """Dedup via 8-byte packet hash (spec Section 16).""" - now = time.monotonic() - if len(_seen_packets) > 500: - expired = [k for k, t in _seen_packets.items() if now - t > DEDUP_TTL] - for k in expired: - del _seen_packets[k] - - pkt_hash = _compute_packet_hash(payload_type, payload) - if pkt_hash in _seen_packets and now - _seen_packets[pkt_hash] < DEDUP_TTL: - return True - _seen_packets[pkt_hash] = now - return False - - -def _is_dm_duplicate(peer_pubkey_hex: str, text: str) -> bool: - """Dedup on decrypted DM text (catches retries with different attempt counters).""" - now = time.monotonic() - if len(_seen_dm_texts) > 200: - expired = [k for k, t in _seen_dm_texts.items() if now - t > DM_DEDUP_TTL] - for k in expired: - del _seen_dm_texts[k] - - key = f"{peer_pubkey_hex}:{text}" - if key in _seen_dm_texts and now - _seen_dm_texts[key] < DM_DEDUP_TTL: - return True - _seen_dm_texts[key] = now - return False - - -# ── History (persistent) ──────────────────────────────────────── - - -def _record_channel_msg(channel: str, text: str): - hist = _state["channel_history"] - if channel not in hist: - hist[channel] = [] - hist[channel].append(text) - # Trim to max size - if len(hist[channel]) > CHANNEL_HISTORY_SIZE: - hist[channel] = hist[channel][-CHANNEL_HISTORY_SIZE:] - _save_state() - - -def _get_channel_history(channel: str) -> list[str]: - return list(_state["channel_history"].get(channel, [])) - - -def _record_dm_msg(peer_pubkey_hex: str, text: str): - hist = _state["dm_history"] - if peer_pubkey_hex not in hist: - hist[peer_pubkey_hex] = [] - hist[peer_pubkey_hex].append(text) - if len(hist[peer_pubkey_hex]) > DM_HISTORY_SIZE: - hist[peer_pubkey_hex] = hist[peer_pubkey_hex][-DM_HISTORY_SIZE:] - _save_state() - - -def _get_dm_history(peer_pubkey_hex: str) -> list[str]: - return list(_state["dm_history"].get(peer_pubkey_hex, [])) - - -# ── Rate limiting ──────────────────────────────────────────────── - -_last_response: dict[str, float] = {} -_last_global_response = 0.0 -_last_rate_limit_reply: dict[str, float] = {} -RATE_LIMIT_REPLY_COOLDOWN = 30 - - -def _rate_limit_check(key: str) -> bool: - global _last_global_response - now = time.monotonic() - if now - _last_global_response < RATE_LIMIT_GLOBAL: - return False - limit = RATE_LIMIT_DM if key.startswith("dm:") else RATE_LIMIT_PER_SENDER - if key in _last_response and now - _last_response[key] < limit: - return False - return True - - -def _rate_limit_reply_ok(key: str) -> bool: - now = time.monotonic() - if key in _last_rate_limit_reply and now - _last_rate_limit_reply[key] < RATE_LIMIT_REPLY_COOLDOWN: - return False - _last_rate_limit_reply[key] = now - return True - - -def _rate_limit_record(key: str): - global _last_global_response - now = time.monotonic() - _last_response[key] = now - _last_global_response = now - - -# ── Bot loop ───────────────────────────────────────────────────── - - -def bot_loop(ser: serial.Serial): - send_cmd(ser, {"type": "Ping"}, "Ping") - send_cmd( - ser, - {"type": "SetConfig", "config": RADIO_CONFIG}, - "SetConfig 910.525/62.5k/SF7/CR4/5", - ) - send_cmd(ser, {"type": "StartRx"}, "StartRx") - - # Send initial ADVERT - _send_advert(ser) - last_advert = time.monotonic() - - print(f"\n {BWHT}{BOT_NAME} listening{RST} {DIM}(Ctrl+C to stop){RST}") - triggers_str = ", ".join(TRIGGERS) - print(f" {DIM}Channels: {len(_KNOWN_CHANNELS)} | Triggers: {triggers_str} | DMs: enabled{RST}\n") - ser.timeout = 1 - - while True: - # Periodic ADVERT - now = time.monotonic() - if now - last_advert >= ADVERT_INTERVAL: - _send_advert(ser) - last_advert = now - - - data = read_frame(ser) - if data is None: - continue - try: - resp = decode_response(data) - except Exception: - continue - if resp["type"] != "RxPacket": - continue - packet = resp["payload"] - parsed = _parse_header_and_path(packet) - if parsed is None: - continue - - payload_type, route_type, payload_ver, pos, raw_payload, path_hops, hash_size = parsed - - # Learn return route from incoming packet's path - if raw_payload and len(raw_payload) >= 2: - if payload_type in (0x01, 0x02, 0x08): # RESPONSE, TXT_MSG, PATH - src_hash = raw_payload[1] - elif payload_type == 0x04: # ADVERT - src_hash = raw_payload[0] - else: - src_hash = None - if src_hash is not None: - _learn_route(src_hash, path_hops, hash_size) - - # Dedup via packet hash (spec Section 16) - if _is_duplicate(payload_type, raw_payload): - continue - - # ── ACK (0x03) — ignore (we fire-and-forget DMs) ── - if payload_type == 0x03: - continue - - # ── ADVERT (0x04) ── - if payload_type == 0x04: - result = _try_decode_advert(raw_payload) - if result: - pubkey, name = result - if pubkey == _pubkey_bytes: - continue # ignore our own ADVERT echo - is_new = _register_node(pubkey, name) - print(f" {BCYN}ADVERT{RST} {name} {DIM}(hash=0x{pubkey[0]:02x} pk={pubkey.hex()[:16]}...){RST}") - - # Check for pending DMs from this node's hash - node_hash = pubkey[0] - if node_hash in _pending_dms and _pending_dms[node_hash]: - now = time.monotonic() - pending = _pending_dms.pop(node_hash) - print(f" {BCYN}Processing {len(pending)} pending DM(s) from {name}{RST}") - for pending_payload, ts in pending: - if now - ts > PENDING_DM_TTL: - continue # expired - dm_result = _try_decrypt_dm(pending_payload) - if dm_result: - peer_pubkey, peer_name, dm_text, dm_plaintext = dm_result - pk_hex = peer_pubkey.hex() - _send_ack(ser, _compute_ack_hash(dm_plaintext, peer_pubkey), peer_pubkey[0]) - if _is_dm_duplicate(pk_hex, dm_text): - continue - print(f" {BCYN}DM(flood){RST} from {BWHT}{peer_name}{RST}: {dm_text}") - _record_dm_msg(pk_hex, f"{peer_name}: {dm_text}") - history = _get_dm_history(pk_hex) - response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}") - if response: - _rt = "direct" if _get_route(peer_pubkey[0]) else "flood" - print(f" {BGRN}<<< DM({_rt}) {BOT_NAME} → {peer_name}: {response}{RST}") - _dm_transmit(ser, peer_pubkey, response) - _record_dm_msg(pk_hex, f"{BOT_NAME}: {response}") - _rate_limit_record(f"dm:{pk_hex}") - continue - - # ── GRP_TXT (0x05) ── - if payload_type == 0x05: - result = _try_decrypt_grp(raw_payload) - if result is None: - continue - - channel_name, text = result - print(f" {BMAG}GRP_TXT{RST} {BGRN}{channel_name}{RST} {BWHT}{text}{RST}") - _record_channel_msg(channel_name, text) - - if ": " not in text: - continue - sender, _, body = text.partition(": ") - if sender == BOT_NAME: - continue - - query = _extract_trigger_query(body) - if query is None: - continue - - print(f" {BMAG}>>> Query from {sender} on {channel_name}: {query}{RST}") - - rl_key = f"ch:{channel_name}:{sender}" - if not _rate_limit_check(rl_key): - print(f" {YEL}Rate limited: {sender} on {channel_name}{RST}") - if _rate_limit_reply_ok(f"ch:{channel_name}"): - msg = _rate_limit_message() - print(f" {BGRN}<<< {BOT_NAME}: {msg}{RST}") - _grp_transmit(ser, channel_name, BOT_NAME, msg) - _rate_limit_record(rl_key) - continue - - history = _get_channel_history(channel_name) - response = call_claude(query, sender, history, MAX_RESPONSE_CHARS, f"channel {channel_name}") - if response is None: - continue - - print(f" {BGRN}<<< {BOT_NAME}: {response}{RST}") - _grp_transmit(ser, channel_name, BOT_NAME, response) - _record_channel_msg(channel_name, f"{BOT_NAME}: {response}") - _rate_limit_record(rl_key) - continue - - # ── ANON_REQ (0x07) — LOGIN from companion (includes full pubkey) ── - if payload_type == 0x07: - result = _try_decrypt_anon_req(raw_payload) - if result is None: - continue - - peer_pubkey, peer_name, plaintext = result - - # Learn route before responding so login response can be direct-routed - _learn_route(peer_pubkey[0], path_hops, hash_size) - - # Send login-OK response so the companion knows we accepted them - print(f" {BCYN}LOGIN{RST} from {BWHT}{peer_name}{RST} — sending login OK") - _send_login_response(ser, peer_pubkey) - - # If we also got a decrypted message, process pending DMs from this peer - pk_hex = peer_pubkey.hex() - node_hash = peer_pubkey[0] - if node_hash in _pending_dms and _pending_dms[node_hash]: - now_mono = time.monotonic() - pending = _pending_dms.pop(node_hash) - print(f" {BCYN}Processing {len(pending)} pending DM(s) from {peer_name}{RST}") - for pending_payload, ts in pending: - if now_mono - ts > PENDING_DM_TTL: - continue - dm_result = _try_decrypt_dm(pending_payload) - if dm_result: - dm_pubkey, dm_name, dm_text, dm_plaintext = dm_result - # ACK + dedup - _send_ack(ser, _compute_ack_hash(dm_plaintext, dm_pubkey), dm_pubkey[0]) - if _is_dm_duplicate(pk_hex, dm_text): - continue - print(f" {BCYN}DM(flood){RST} from {BWHT}{dm_name}{RST}: {dm_text}") - _record_dm_msg(pk_hex, f"{dm_name}: {dm_text}") - history = _get_dm_history(pk_hex) - response = call_claude(dm_text, dm_name, history, MAX_DM_TEXT, f"DM with {dm_name}") - if response: - _rt = "direct" if _get_route(dm_pubkey[0]) else "flood" - print(f" {BGRN}<<< DM({_rt}) {BOT_NAME} → {dm_name}: {response}{RST}") - _dm_transmit(ser, dm_pubkey, response) - _record_dm_msg(pk_hex, f"{BOT_NAME}: {response}") - _rate_limit_record(f"dm:{pk_hex}") - continue - - # ── TXT_MSG DM (0x02) ── - if payload_type == 0x02: - result = _try_decrypt_dm(raw_payload) - if result is None: - # If addressed to us but sender unknown, queue for later - if len(raw_payload) >= 4 and raw_payload[0] == _my_hash(): - src_hash = raw_payload[1] - if not _lookup_node_by_hash(src_hash): - _pending_dms.setdefault(src_hash, []).append((raw_payload, time.monotonic())) - if len(_pending_dms[src_hash]) == 1: - print(f" {BCYN}Queued DM from unknown 0x{src_hash:02x}, waiting for their ADVERT{RST}") - continue - - peer_pubkey, peer_name, dm_text, raw_plaintext = result - - # Send ACK immediately so the sender stops retrying - ack_crc = _compute_ack_hash(raw_plaintext, peer_pubkey) - _send_ack(ser, ack_crc, peer_pubkey[0]) - - # Send PATH return so sender can learn a direct route to us - _send_path_return(ser, peer_pubkey, path_hops, hash_size) - - pk_hex = peer_pubkey.hex() - - # Dedup on decrypted text (catches retries with different attempt counters) - if _is_dm_duplicate(pk_hex, dm_text): - continue - - print(f" {BCYN}DM({_route_name(route_type)}){RST} from {BWHT}{peer_name}{RST}: {dm_text}") - _record_dm_msg(pk_hex, f"{peer_name}: {dm_text}") - - rl_key = f"dm:{pk_hex}" - if not _rate_limit_check(rl_key): - print(f" {YEL}Rate limited DM: {peer_name}{RST}") - continue - - history = _get_dm_history(pk_hex) - response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}") - if response is None: - continue - - _rt = "direct" if _get_route(peer_pubkey[0]) else "flood" - print(f" {BGRN}<<< DM({_rt}) {BOT_NAME} → {peer_name}: {response}{RST}") - _dm_transmit(ser, peer_pubkey, response) - _record_dm_msg(pk_hex, f"{BOT_NAME}: {response}") - _rate_limit_record(rl_key) - continue - - - -# ── Main ───────────────────────────────────────────────────────── - - -def main(): - if not os.environ.get("ANTHROPIC_API_KEY"): - print(f" {RED}ANTHROPIC_API_KEY environment variable not set{RST}") - sys.exit(1) - - _init_identity() - _load_state() - - print(f" {BWHT}{BOT_NAME}{RST} — MeshCore AI Bot") - print(f" {DIM}Max GRP response: {MAX_RESPONSE_CHARS} chars | Max DM response: {MAX_DM_TEXT} chars{RST}") - print(f" {DIM}Channels: {len(_KNOWN_CHANNELS)} | Known nodes: {len(_state['known_nodes'])}{RST}") - - port = sys.argv[1] if len(sys.argv) > 1 else None - - while True: - try: - print(f" {DIM}Connecting...{RST}") - ser = open_connection(port) - print(f" {GRN}Connected{RST}") - bot_loop(ser) - except (serial.SerialException, ConnectionError, OSError) as e: - print(f"\n {RED}Disconnected: {e}{RST}") - print(f" {DIM}Reconnecting when device reappears...{RST}") - time.sleep(1) - except KeyboardInterrupt: - print() - _save_state() - try: - ser.timeout = 2 - send_cmd(ser, {"type": "StopRx"}, "StopRx") - except Exception: - pass - break - +from orac.bot import main if __name__ == "__main__": main() diff --git a/orac/__init__.py b/orac/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orac/ai.py b/orac/ai.py new file mode 100644 index 0000000..5bad7cc --- /dev/null +++ b/orac/ai.py @@ -0,0 +1,188 @@ +"""Claude API integration: query extraction, response shortening.""" + +from __future__ import annotations + +import logging +import random +import time + +import anthropic + +from orac.constants import ( + AT_MENTIONS, + MAX_RESPONSE_CHARS, + SYSTEM_PROMPT, + TRIGGERS, +) + +log = logging.getLogger("orac") + +# ── Anthropic client singleton ─────────────────────────────────── + +_client: anthropic.Anthropic | None = None + + +def _get_client() -> anthropic.Anthropic: + global _client + if _client is None: + _client = anthropic.Anthropic() + return _client + + +# ── Response shortening ────────────────────────────────────────── + + +def _shorten_with_claude(text: str, max_chars: int) -> str: + """Use Claude to shorten text to fit within max_chars.""" + try: + resp = _get_client().messages.create( + model="claude-sonnet-4-6", + max_tokens=256, + messages=[ + { + "role": "user", + "content": f"Shorten this to {max_chars} characters: {text}", + } + ], + ) + shortened: str = resp.content[0].text.strip() + except Exception as e: + log.error("Shorten API error: %s", e) + shortened = text + encoded = shortened.encode("utf-8") + if len(encoded) > max_chars: + while len(shortened.encode("utf-8")) > max_chars - 1: + shortened = shortened[:-1] + shortened += "\u2026" + return shortened + + +def _extract_text(resp: anthropic.types.Message) -> str: + """Extract the last non-empty text block from a Claude response.""" + text = "" + for block in resp.content: + if block.type == "text" and block.text.strip(): + text = block.text.strip() + return text + + +# ── Main query function ───────────────────────────────────────── + + +def call_claude( + query: str, + sender: str, + history: list[str], + max_chars: int, + context_label: str, +) -> str | None: + """Send a query to Claude with conversation context. Returns response text or None.""" + try: + nonce = random.randint(1000, 9999) + now = time.strftime("%Y-%m-%d %H:%M %Z") + prompt = ( + SYSTEM_PROMPT.replace("{max_chars}", str(max_chars)) + f"\n\nCurrent date/time: {now}" + ) + + if history: + context = "\n".join(history) + user_content = ( + f"[seed:{nonce}]\n" + f"Recent messages in {context_label}:\n{context}\n\n" + f"{sender} says: {query}" + ) + else: + user_content = f"[seed:{nonce}] {sender} says: {query}" + + messages: list[dict[str, object]] = [{"role": "user", "content": user_content}] + tools: list[dict[str, str]] = [{"type": "web_search_20260209", "name": "web_search"}] + + text = "" + for round_num in range(3): + resp = _get_client().messages.create( + model="claude-sonnet-4-6", + max_tokens=4096, + temperature=1.0, + system=prompt, + tools=tools, + messages=messages, + ) + + text = _extract_text(resp) + if text: + break + + if resp.stop_reason != "tool_use": + block_types = [b.type for b in resp.content] + log.warning("No text, stop=%s, blocks=%s", resp.stop_reason, block_types) + break + + log.info("(web search round %d)...", round_num + 1) + messages.append({"role": "assistant", "content": resp.content}) + tool_results: list[dict[str, str]] = [] + for block in resp.content: + if hasattr(block, "id") and block.type == "server_tool_use": + tool_results.append( + {"type": "tool_result", "tool_use_id": block.id, "content": ""} + ) + if tool_results: + messages.append({"role": "user", "content": tool_results}) + else: + break + + if not text: + log.warning("No text after tool loop") + return None + except Exception as e: + log.error("Claude API error: %s", e) + return None + + if len(text.encode("utf-8")) > max_chars: + log.warning("Response too long (%dB), shortening...", len(text.encode("utf-8"))) + text = _shorten_with_claude(text, max_chars) + + return text + + +# ── Rate-limit message ─────────────────────────────────────────── + + +def rate_limit_message() -> str: + """Generate a witty rate-limit message via Claude.""" + try: + nonce = random.randint(1000, 9999) + resp = _get_client().messages.create( + model="claude-sonnet-4-6", + max_tokens=100, + temperature=1.0, + system=( + f"You are Orac, a sardonic AI on a radio mesh network. " + f"Generate a single short message ({MAX_RESPONSE_CHARS} chars max, ASCII only) " + f"telling someone to slow down and try again in a few seconds. " + f"Be witty, varied, and in-character. No quotes around the message." + ), + messages=[{"role": "user", "content": f"[seed:{nonce}] Rate limit hit."}], + ) + text: str = resp.content[0].text.strip().strip("\"'") + if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text: + return text + except Exception: + pass + return "Patience. Try again in a moment." + + +# ── Trigger detection ──────────────────────────────────────────── + + +def extract_trigger_query(text: str) -> str | None: + """Check for !triggers and @mentions. Returns query text or None.""" + lower = text.lower() + for trigger in (*TRIGGERS, *AT_MENTIONS): + idx = lower.find(trigger) + if idx != -1: + before = text[:idx] + after = text[idx + len(trigger) :] + query = (before + " " + after).strip() + if query: + return query + return None diff --git a/orac/bot.py b/orac/bot.py new file mode 100644 index 0000000..cab2015 --- /dev/null +++ b/orac/bot.py @@ -0,0 +1,478 @@ +"""Main bot loop, packet dispatch, ADVERT scheduling, connection management, CLI.""" + +from __future__ import annotations + +import logging +import os +import sys +import time +from typing import Any, ClassVar + +import serial + +import donglora as dl +from orac.ai import call_claude, extract_trigger_query, rate_limit_message +from orac.constants import ( + ADVERT_INTERVAL, + BOT_NAME, + MAX_DM_TEXT, + MAX_RESPONSE_CHARS, + RADIO_CONFIG, + TRIGGERS, +) +from orac.crypto import ( + channel_count, + init_channels, + init_identity, + my_hash, + pubkey_bytes, +) +from orac.meshcore import ( + build_ack_packet, + build_advert_packet, + build_dm_packet, + build_grp_channel_packet, + build_login_response_packet, + build_path_return_packet, + compute_ack_hash, + get_route, + has_pending_dms, + is_dm_duplicate, + is_duplicate, + is_pending_expired, + learn_route, + lookup_node_by_hash, + node_name, + parse_header_and_path, + pop_pending_dms, + queue_pending_dm, + register_node, + route_name, + try_decode_advert, + try_decrypt_anon_req, + try_decrypt_dm, + try_decrypt_grp, +) +from orac.state import ( + get_channel_history, + get_dm_history, + known_node_count, + load_state, + record_channel_msg, + record_dm_msg, + save_state, +) + +log = logging.getLogger("orac") + +# ── Rate limiting (bot-level timers) ───────────────────────────── + +_last_response: dict[str, float] = {} +_last_global_response: float = 0.0 +_last_rate_limit_reply: dict[str, float] = {} + + +def _rate_limit_check(key: str) -> bool: + """Check if we're allowed to respond. Returns True if OK.""" + global _last_global_response + from orac.constants import RATE_LIMIT_DM, RATE_LIMIT_GLOBAL, RATE_LIMIT_PER_SENDER + + now = time.monotonic() + if now - _last_global_response < RATE_LIMIT_GLOBAL: + return False + limit = RATE_LIMIT_DM if key.startswith("dm:") else RATE_LIMIT_PER_SENDER + return not (key in _last_response and now - _last_response[key] < limit) + + +def _rate_limit_reply_ok(key: str) -> bool: + """Check if we should send a rate-limit reply (not too often).""" + from orac.constants import RATE_LIMIT_REPLY_COOLDOWN + + now = time.monotonic() + if ( + key in _last_rate_limit_reply + and now - _last_rate_limit_reply[key] < RATE_LIMIT_REPLY_COOLDOWN + ): + return False + _last_rate_limit_reply[key] = now + return True + + +def _rate_limit_record(key: str) -> None: + """Record that we just responded.""" + global _last_global_response + now = time.monotonic() + _last_response[key] = now + _last_global_response = now + + +# ── Transmit helpers ───────────────────────────────────────────── + + +def _transmit_packet(conn: Any, packet: bytes, label: str = "Transmit") -> None: # noqa: ARG001 + """Transmit a raw MeshCore packet via the donglora library.""" + try: + resp = dl.send(conn, "Transmit", payload=packet) + if resp["type"] == "Timeout": + log.error("TX failed: no response from radio") + elif resp["type"] == "Error": + log.error("TX failed: %s", resp) + except Exception as e: + log.error("TX exception: %s", e) + + +def _send_advert(conn: Any) -> None: + """Transmit our ADVERT.""" + packet = build_advert_packet() + log.info("Sending ADVERT as %s (hash=0x%02x)", BOT_NAME, my_hash()) + _transmit_packet(conn, packet, label="ADVERT") + + +def _grp_transmit(conn: Any, channel_name: str, sender: str, text: str) -> None: + """Encrypt and transmit a GRP_TXT on a channel.""" + packet = build_grp_channel_packet(channel_name, sender, text) + if packet is None: + log.error("TX failed: unknown channel %s", channel_name) + return + _transmit_packet(conn, packet) + + +def _dm_transmit(conn: Any, peer_pubkey: bytes, text: str) -> None: + """Encrypt and transmit a DM with simulated typing delay.""" + delay = min(max(len(text) * 0.04, 1.0), 5.0) + time.sleep(delay) + packet = build_dm_packet(peer_pubkey, text) + peer = node_name(peer_pubkey.hex()) + route_str = "direct" if get_route(peer_pubkey[0]) else "flood" + _transmit_packet(conn, packet, label=f"DM({route_str})->{peer}") + + +def _send_ack(conn: Any, ack_crc: bytes, dest_hash: int) -> None: + """Send an ACK with a brief delay for the DM flood to clear.""" + time.sleep(0.5) + packet = build_ack_packet(ack_crc, dest_hash) + _transmit_packet(conn, packet, label="ACK") + + +def _send_path_return( + conn: Any, peer_pubkey: bytes, path_hops: list[bytes], hash_size: int +) -> None: + """Send a PATH return so the sender can learn a direct route to us.""" + packet = build_path_return_packet(peer_pubkey, path_hops, hash_size) + _transmit_packet(conn, packet, label="PATH") + + +def _send_login_response(conn: Any, peer_pubkey: bytes) -> None: + """Send a login OK response.""" + packet = build_login_response_packet(peer_pubkey) + _transmit_packet(conn, packet, label=f"LOGIN_RESP->{node_name(peer_pubkey.hex())}") + + +# ── Pending DM processing ─────────────────────────────────────── + + +def _process_pending_dms(conn: Any, node_hash: int, peer_label: str) -> None: + """Process queued DMs that were waiting for an ADVERT or ANON_REQ.""" + pending = pop_pending_dms(node_hash) + if not pending: + return + log.info("Processing %d pending DM(s) from %s", len(pending), peer_label) + for pending_payload, ts in pending: + if is_pending_expired(ts): + continue + dm_result = try_decrypt_dm(pending_payload) + if dm_result is None: + continue + peer_pubkey, peer_name, dm_text, dm_plaintext = dm_result + pk_hex = peer_pubkey.hex() + _send_ack(conn, compute_ack_hash(dm_plaintext, peer_pubkey), peer_pubkey[0]) + if is_dm_duplicate(pk_hex, dm_text): + continue + log.info("DM(flood) from %s: %s", peer_name, dm_text) + record_dm_msg(pk_hex, f"{peer_name}: {dm_text}") + history = get_dm_history(pk_hex) + response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}") + if response: + rt = "direct" if get_route(peer_pubkey[0]) else "flood" + log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response) + _dm_transmit(conn, peer_pubkey, response) + record_dm_msg(pk_hex, f"{BOT_NAME}: {response}") + _rate_limit_record(f"dm:{pk_hex}") + + +# ── Packet dispatch ────────────────────────────────────────────── + + +def _handle_advert(conn: Any, raw_payload: bytes) -> None: + """Handle an incoming ADVERT packet.""" + result = try_decode_advert(raw_payload) + if result is None: + return + adv_pubkey, name = result + if adv_pubkey == pubkey_bytes(): + return # ignore our own ADVERT echo + register_node(adv_pubkey, name) + log.info("ADVERT %s (hash=0x%02x pk=%s...)", name, adv_pubkey[0], adv_pubkey.hex()[:16]) + + if has_pending_dms(adv_pubkey[0]): + _process_pending_dms(conn, adv_pubkey[0], name) + + +def _handle_grp_txt(conn: Any, raw_payload: bytes) -> None: + """Handle an incoming GRP_TXT packet.""" + result = try_decrypt_grp(raw_payload) + if result is None: + return + + channel_name, text = result + log.info("GRP_TXT %s %s", channel_name, text) + record_channel_msg(channel_name, text) + + if ": " not in text: + return + sender, _, body = text.partition(": ") + if sender == BOT_NAME: + return + + query = extract_trigger_query(body) + if query is None: + return + + log.info(">>> Query from %s on %s: %s", sender, channel_name, query) + + rl_key = f"ch:{channel_name}:{sender}" + if not _rate_limit_check(rl_key): + log.warning("Rate limited: %s on %s", sender, channel_name) + if _rate_limit_reply_ok(f"ch:{channel_name}"): + msg = rate_limit_message() + log.info("<<< %s: %s", BOT_NAME, msg) + _grp_transmit(conn, channel_name, BOT_NAME, msg) + _rate_limit_record(rl_key) + return + + history = get_channel_history(channel_name) + response = call_claude(query, sender, history, MAX_RESPONSE_CHARS, f"channel {channel_name}") + if response is None: + return + + log.info("<<< %s: %s", BOT_NAME, response) + _grp_transmit(conn, channel_name, BOT_NAME, response) + record_channel_msg(channel_name, f"{BOT_NAME}: {response}") + _rate_limit_record(rl_key) + + +def _handle_anon_req( + conn: Any, + raw_payload: bytes, + path_hops: list[bytes], + hash_size: int, +) -> None: + """Handle an incoming ANON_REQ (login) packet.""" + result = try_decrypt_anon_req(raw_payload) + if result is None: + return + + peer_pubkey, peer_name, _plaintext = result + + learn_route(peer_pubkey[0], path_hops, hash_size) + + log.info("LOGIN from %s -- sending login OK", peer_name) + _send_login_response(conn, peer_pubkey) + + if has_pending_dms(peer_pubkey[0]): + _process_pending_dms(conn, peer_pubkey[0], peer_name) + + +def _handle_txt_msg( + conn: Any, + raw_payload: bytes, + route_type: int, + path_hops: list[bytes], + hash_size: int, +) -> None: + """Handle an incoming TXT_MSG (DM) packet.""" + result = try_decrypt_dm(raw_payload) + if result is None: + # If addressed to us but sender unknown, queue for later + if len(raw_payload) >= 4 and raw_payload[0] == my_hash(): + src_hash = raw_payload[1] + if not lookup_node_by_hash(src_hash): + is_first = queue_pending_dm(src_hash, raw_payload) + if is_first: + log.info("Queued DM from unknown 0x%02x, waiting for their ADVERT", src_hash) + return + + peer_pubkey, peer_name, dm_text, raw_plaintext = result + + # Send ACK immediately so the sender stops retrying + ack_crc = compute_ack_hash(raw_plaintext, peer_pubkey) + _send_ack(conn, ack_crc, peer_pubkey[0]) + + # Send PATH return so sender can learn a direct route to us + _send_path_return(conn, peer_pubkey, path_hops, hash_size) + + pk_hex = peer_pubkey.hex() + + if is_dm_duplicate(pk_hex, dm_text): + return + + log.info("DM(%s) from %s: %s", route_name(route_type), peer_name, dm_text) + record_dm_msg(pk_hex, f"{peer_name}: {dm_text}") + + rl_key = f"dm:{pk_hex}" + if not _rate_limit_check(rl_key): + log.warning("Rate limited DM: %s", peer_name) + return + + history = get_dm_history(pk_hex) + response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}") + if response is None: + return + + rt = "direct" if get_route(peer_pubkey[0]) else "flood" + log.info("<<< DM(%s) %s -> %s: %s", rt, BOT_NAME, peer_name, response) + _dm_transmit(conn, peer_pubkey, response) + record_dm_msg(pk_hex, f"{BOT_NAME}: {response}") + _rate_limit_record(rl_key) + + +# ── Bot loop ───────────────────────────────────────────────────── + + +def bot_loop(conn: Any) -> None: + """Main receive loop: read packets and dispatch.""" + dl.send(conn, "Ping") + dl.send(conn, "SetConfig", config=RADIO_CONFIG) + dl.send(conn, "StartRx") + + _send_advert(conn) + last_advert = time.monotonic() + + triggers_str = ", ".join(TRIGGERS) + log.info("%s listening (Ctrl+C to stop)", BOT_NAME) + log.info("Channels: %d | Triggers: %s | DMs: enabled", channel_count(), triggers_str) + conn.timeout = 1 + + while True: + # Periodic ADVERT + now = time.monotonic() + if now - last_advert >= ADVERT_INTERVAL: + _send_advert(conn) + last_advert = now + + pkt = dl.recv(conn) + if pkt is None: + continue + + packet: bytes = pkt["payload"] + parsed = parse_header_and_path(packet) + if parsed is None: + continue + + payload_type, route_type, _payload_ver, _pos, raw_payload, path_hops, hash_size = parsed + + # Learn return route from incoming packet's path + if raw_payload and len(raw_payload) >= 2: + src_hash: int | None = None + if payload_type in (0x01, 0x02, 0x08): # RESPONSE, TXT_MSG, PATH + src_hash = raw_payload[1] + elif payload_type == 0x04: # ADVERT + src_hash = raw_payload[0] + if src_hash is not None: + learn_route(src_hash, path_hops, hash_size) + + # Dedup via packet hash (spec Section 16) + if is_duplicate(payload_type, raw_payload): + continue + + # Dispatch by payload type + if payload_type == 0x03: # ACK -- ignore (fire-and-forget) + continue + elif payload_type == 0x04: # ADVERT + _handle_advert(conn, raw_payload) + elif payload_type == 0x05: # GRP_TXT + _handle_grp_txt(conn, raw_payload) + elif payload_type == 0x07: # ANON_REQ (login) + _handle_anon_req(conn, raw_payload, path_hops, hash_size) + elif payload_type == 0x02: # TXT_MSG (DM) + _handle_txt_msg(conn, raw_payload, route_type, path_hops, hash_size) + + +# ── Logging setup ──────────────────────────────────────────────── + + +class _ColorFormatter(logging.Formatter): + """Log formatter that adds ANSI colors when stderr is a TTY.""" + + _COLORS: ClassVar[dict[int, str]] = { + logging.DEBUG: "\033[2m", # dim + logging.INFO: "\033[32m", # green + logging.WARNING: "\033[33m", # yellow + logging.ERROR: "\033[31m", # red + logging.CRITICAL: "\033[1;31m", # bold red + } + _RESET = "\033[0m" + + def __init__(self, use_color: bool = True) -> None: + super().__init__(fmt=" %(message)s") + self._use_color = use_color + + def format(self, record: logging.LogRecord) -> str: + msg = super().format(record) + if self._use_color: + color = self._COLORS.get(record.levelno, "") + return f"{color}{msg}{self._RESET}" + return msg + + +def _setup_logging() -> None: + """Configure the orac logger with colored console output.""" + logger = logging.getLogger("orac") + logger.setLevel(logging.DEBUG) + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(_ColorFormatter(use_color=sys.stderr.isatty())) + logger.addHandler(handler) + + +# ── Entry point ────────────────────────────────────────────────── + + +def main() -> None: + """CLI entry point.""" + _setup_logging() + + if not os.environ.get("ANTHROPIC_API_KEY"): + log.error("ANTHROPIC_API_KEY environment variable not set") + sys.exit(1) + + init_identity() + init_channels() + load_state() + + log.info("%s -- MeshCore AI Bot", BOT_NAME) + log.info( + "Max GRP response: %d chars | Max DM response: %d chars", MAX_RESPONSE_CHARS, MAX_DM_TEXT + ) + log.info("Channels: %d | Known nodes: %d", channel_count(), known_node_count()) + + port = sys.argv[1] if len(sys.argv) > 1 else None + + while True: + try: + log.info("Connecting...") + conn = dl.connect(port=port, timeout=2) + log.info("Connected") + bot_loop(conn) + except (serial.SerialException, ConnectionError, OSError) as e: + log.error("Disconnected: %s", e) + log.info("Reconnecting when device reappears...") + time.sleep(1) + except KeyboardInterrupt: + print() + save_state() + try: + conn.timeout = 2 # type: ignore[possibly-undefined] + dl.send(conn, "StopRx") # type: ignore[possibly-undefined] + except Exception: + pass + break diff --git a/orac/constants.py b/orac/constants.py new file mode 100644 index 0000000..2aae5f6 --- /dev/null +++ b/orac/constants.py @@ -0,0 +1,96 @@ +"""Orac bot constants: names, radio config, triggers, limits, paths.""" + +from __future__ import annotations + +from pathlib import Path + +# ── Bot identity ───────────────────────────────────────────────── + +BOT_NAME: str = "Orac" + +# ── Payload limits ─────────────────────────────────────────────── + +MAX_GRP_TEXT: int = 163 # max bytes for "SenderName: message" in a GRP_TXT +MAX_RESPONSE_CHARS: int = MAX_GRP_TEXT - len(BOT_NAME) - 2 # subtract "Orac: " +# DM plaintext: timestamp(4) + txt_type_attempt(1) + text + padding to 16 +# Max payload 184 - dst(1) - src(1) - mac(2) = 180 bytes ciphertext +# 180 bytes ciphertext / 16 = 11 blocks -> 176 bytes plaintext max +# 176 - 5 (header) = 171 bytes text, minus null terminator padding +MAX_DM_TEXT: int = 170 + +# ── Trigger words ──────────────────────────────────────────────── + +TRIGGERS: tuple[str, ...] = ("!askai", "!ai", "!claude", "!orac") +AT_MENTIONS: tuple[str, ...] = (f"@{BOT_NAME}".lower(), f"@[{BOT_NAME}]".lower()) + +# ── History / rate-limit sizing ────────────────────────────────── + +CHANNEL_HISTORY_SIZE: int = 20 +DM_HISTORY_SIZE: int = 20 + +RATE_LIMIT_PER_SENDER: float = 5 # seconds, channel messages +RATE_LIMIT_GLOBAL: float = 2 # seconds, any two responses +RATE_LIMIT_DM: float = 2 # seconds, DMs (more interactive) +RATE_LIMIT_REPLY_COOLDOWN: float = 30 # seconds between rate-limit replies + +# ── Timers ─────────────────────────────────────────────────────── + +ADVERT_INTERVAL: float = 7200 # 2 hours in seconds + +# ── Paths ──────────────────────────────────────────────────────── + +DATA_DIR: Path = Path.home() / ".donglora" +KEY_FILE: Path = DATA_DIR / "orac_key.bin" +STATE_FILE: Path = DATA_DIR / "orac_state.json" + +# ── Radio ──────────────────────────────────────────────────────── + +RADIO_CONFIG: dict[str, int] = { + "freq_hz": 910_525_000, + "bw": 6, # 62.5 kHz + "sf": 7, + "cr": 5, # CR 4/5 + "sync_word": 0x3444, + "tx_power_dbm": -128, # TX_POWER_MAX +} + +# ── AI system prompt ───────────────────────────────────────────── + +SYSTEM_PROMPT: str = """\ +You are Orac, a terse AI assistant on a MeshCore LoRa radio mesh network. + +ABSOLUTE HARD LIMIT: Your ENTIRE response must be {{max_chars}} characters or fewer. +This is a physical constraint of the radio protocol. Every character beyond this limit +is permanently lost and never transmitted. There is NO exception. + +Stay well under {{max_chars}}. Aim for 120 or fewer. NEVER show a character count or HTML tags in your reply. + +You can see recent messages for context. When someone asks you a question, they may +be referring to the ongoing conversation — use the chat history to understand what they mean. +For example, "what do you think?" refers to whatever was just discussed. "tell me more" means +elaborate on the recent topic. Read the room. + +Rules: +- One sentence or short phrase. Never more. +- Emoji OK but don't overdo it. No markdown, no bullet points, no formatting. +- Use common abbreviations freely (e.g., w/, b/c, approx, etc). +- Skip all pleasantries, greetings, and filler. +- You have a dry, sardonic wit. Be helpful but never wordy. +- If the question requires a long answer, give the most important point only. +- NEVER repeat a previous answer. Always find a fresh angle, new phrasing, or different take. +- Be creative and unpredictable. Surprise the reader.\ +""" + +# ── MeshCore protocol constants ────────────────────────────────── + +ADVERT_MAX_AGE: float = 43200 # 12 hours (display only, not key expiry) +PENDING_DM_TTL: float = 300 # 5 minutes + +DEDUP_TTL: float = 120 # seconds +DM_DEDUP_TTL: float = 60 # seconds + +RESP_SERVER_LOGIN_OK: int = 0x01 + +# ── Channels CSV path ──────────────────────────────────────────── +# Resolved at import time relative to the project root (ai_bot.py's directory). +CHANNELS_CSV: Path = Path(__file__).resolve().parent.parent / "channels.csv" diff --git a/orac/crypto.py b/orac/crypto.py new file mode 100644 index 0000000..623ca8b --- /dev/null +++ b/orac/crypto.py @@ -0,0 +1,326 @@ +"""Identity (Ed25519 keypair), channel crypto, peer crypto, ECDH.""" + +from __future__ import annotations + +import csv +import hashlib +import hmac +import logging +import struct +import time + +from Crypto.Cipher import AES +from nacl.bindings import crypto_scalarmult +from nacl.signing import SigningKey, VerifyKey + +from orac.constants import ( + BOT_NAME, + CHANNELS_CSV, + DATA_DIR, + KEY_FILE, + MAX_GRP_TEXT, +) + +log = logging.getLogger("orac") + +# ── Identity ───────────────────────────────────────────────────── + +_signing_key: SigningKey | None = None +_verify_key: VerifyKey | None = None +_pubkey_bytes: bytes = b"" + + +def init_identity() -> None: + """Load or generate Ed25519 keypair. Persisted to disk.""" + global _signing_key, _verify_key, _pubkey_bytes + + DATA_DIR.mkdir(parents=True, exist_ok=True) + + if KEY_FILE.is_file(): + seed = KEY_FILE.read_bytes() + if len(seed) == 32: + _signing_key = SigningKey(seed) + log.info("Loaded keypair from %s", KEY_FILE) + else: + log.warning("Invalid key file, regenerating") + _signing_key = SigningKey.generate() + KEY_FILE.write_bytes(bytes(_signing_key)) + else: + _signing_key = SigningKey.generate() + KEY_FILE.write_bytes(bytes(_signing_key)) + log.info("Generated new keypair -> %s", KEY_FILE) + + _verify_key = _signing_key.verify_key + _pubkey_bytes = bytes(_verify_key) + log.info("Pubkey: %s", _pubkey_bytes.hex()) + log.info("Node hash: 0x%02x", _pubkey_bytes[0]) + + +def my_hash() -> int: + """Our 1-byte node hash (first byte of pubkey).""" + return _pubkey_bytes[0] + + +def pubkey_bytes() -> bytes: + """Our Ed25519 public key (32 bytes).""" + return _pubkey_bytes + + +def sign(message: bytes) -> bytes: + """Sign *message* with our identity key. Returns the 64-byte signature.""" + assert _signing_key is not None + signed = _signing_key.sign(message) + return signed.signature + + +# ── Channel crypto ─────────────────────────────────────────────── + + +def channel_secret_from_hashtag(name: str) -> bytes: + """Derive a 32-byte channel secret from a hashtag name.""" + h = hashlib.sha256(name.encode()).digest() + return h[:16] + b"\x00" * 16 + + +def channel_hash(secret: bytes) -> int: + """Compute the 1-byte channel hash from a secret.""" + key_len = 16 if secret[16:] == b"\x00" * 16 else 32 + return hashlib.sha256(secret[:key_len]).digest()[0] + + +def grp_verify_and_decrypt(secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None: + """Verify MAC and decrypt a group message. Returns plaintext or None.""" + key_len = 32 # HMAC always uses full 32-byte secret (spec Section 14) + computed = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2] + if computed != mac_bytes: + return None + cipher = AES.new(secret[:16], AES.MODE_ECB) + plaintext = b"" + for i in range(0, len(ciphertext), 16): + plaintext += cipher.decrypt(ciphertext[i : i + 16]) + return plaintext + + +def parse_grp_plaintext(plaintext: bytes) -> tuple[int, str] | None: + """Parse decrypted group plaintext. Returns (timestamp, text) or None.""" + if len(plaintext) < 5: + return None + timestamp: int = struct.unpack_from(" bytes: + """Encrypt a GRP_TXT payload. Returns channel_hash + mac + ciphertext.""" + ch = channel_hash(secret) + plaintext = struct.pack(" bytes: + """Compute X25519 shared secret from our Ed25519 key and peer's Ed25519 pubkey.""" + assert _signing_key is not None + my_x25519 = bytes(_signing_key.to_curve25519_private_key()) + peer_verify = VerifyKey(peer_ed25519_pub) + peer_x25519 = bytes(peer_verify.to_curve25519_public_key()) + return crypto_scalarmult(my_x25519, peer_x25519) + + +def peer_encrypt(shared_secret: bytes, text: str) -> bytes: + """Encrypt a DM plaintext. Returns mac(2) + ciphertext.""" + plaintext = struct.pack(" bytes | None: + """Verify MAC then decrypt a peer message. Returns plaintext or None.""" + computed = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2] + if computed != mac_bytes: + return None + cipher = AES.new(shared_secret[:16], AES.MODE_ECB) + plaintext = b"" + for i in range(0, len(ciphertext), 16): + plaintext += cipher.decrypt(ciphertext[i : i + 16]) + return plaintext + + +def parse_peer_plaintext(plaintext: bytes) -> str | None: + """Parse decrypted peer message: timestamp(4) + txt_type_attempt(1) + text.""" + if len(plaintext) < 5: + return None + text = plaintext[5:].split(b"\x00", 1)[0] + return text.decode("utf-8", errors="replace") if text else None + + +def verify_advert_signature( + pubkey: bytes, timestamp: bytes, app_data: bytes, signature: bytes +) -> bool: + """Verify an ADVERT's Ed25519 signature. Returns True on success.""" + try: + verify_key = VerifyKey(pubkey) + sign_msg = pubkey + timestamp + app_data + verify_key.verify(sign_msg, signature) + return True + except Exception: + return False + + +# ── Channel registry ───────────────────────────────────────────── + +_channels: dict[str, bytes] = {} +_channel_by_hash: dict[int, list[tuple[str, bytes]]] = {} + + +def register_channel(name: str, secret: bytes) -> None: + """Register a channel name and its secret.""" + _channels[name] = secret + h = channel_hash(secret) + _channel_by_hash.setdefault(h, []).append((name, secret)) + + +def get_channel_secret(name: str) -> bytes | None: + """Get the secret for a registered channel, or None.""" + return _channels.get(name) + + +def get_channels_by_hash(h: int) -> list[tuple[str, bytes]]: + """Get all channel (name, secret) pairs matching a hash byte.""" + return _channel_by_hash.get(h, []) + + +def channel_count() -> int: + """Number of registered channels.""" + return len(_channels) + + +_PUBLIC_PSK_HEX: str = "8b3387e9c5cdea6ac9e5edbaa115cd72" +"""MeshCore Public channel pre-shared key (fixed, not hashtag-derived).""" + +_BUILTIN_HASHTAG_CHANNELS: tuple[str, ...] = ( + # --- AI / bot trigger channels --- + "#askai", + "#ai", + "#aibot", + "#aibots", + "#orac", + "#bot", + # --- Operations --- + "#nodeops", + "#repeater-ops", + "#net", + "#emcomm", + # --- General purpose --- + "#general", + "#test", + "#weather", + "#protest", + # --- Dev / testing --- + "#devtest", + "#devtestdevtest", +) +"""Built-in hashtag channels (key derived from SHA-256 of the name).""" + + +def init_channels() -> None: + """Register the MeshCore Public channel, built-in hashtag channels, and any CSV overrides.""" + # Public channel — fixed PSK, not hashtag-derived + register_channel("Public", bytes.fromhex(_PUBLIC_PSK_HEX) + b"\x00" * 16) + + # Built-in hashtag channels + for name in _BUILTIN_HASHTAG_CHANNELS: + register_channel(name, channel_secret_from_hashtag(name)) + + # Optional CSV for additional channels (operator-defined) + if CHANNELS_CSV.is_file(): + with open(CHANNELS_CSV, newline="") as f: + for row in csv.DictReader(f): + name: str = row["channel_name"] + is_hashtag = row["hashtag"].strip().lower() == "true" + key_hex: str = row["key_hex"].strip() + if is_hashtag: + secret = channel_secret_from_hashtag(name) + else: + secret = bytes.fromhex(key_hex) + b"\x00" * 16 + register_channel(name, secret) + + +def aes_ecb_encrypt(key: bytes, plaintext: bytes) -> bytes: + """AES-ECB encrypt (16-byte aligned plaintext). Used for PATH and RESPONSE.""" + cipher = AES.new(key[:16], AES.MODE_ECB) + ciphertext = b"" + for i in range(0, len(plaintext), 16): + ciphertext += cipher.encrypt(plaintext[i : i + 16]) + return ciphertext + + +def compute_mac(secret: bytes, ciphertext: bytes) -> bytes: + """Compute 2-byte HMAC-SHA256 MAC over ciphertext.""" + return hmac.new(secret[:32], ciphertext, hashlib.sha256).digest()[:2] + + +def build_advert_payload() -> bytes: + """Build the inner ADVERT payload: pubkey + timestamp + signature + app_data.""" + timestamp = struct.pack(" bytes: + """Encrypt raw plaintext (not text message) for a peer. Returns mac(2) + ciphertext.""" + pad_len = (16 - len(plaintext) % 16) % 16 + padded = plaintext + b"\x00" * pad_len + ciphertext = aes_ecb_encrypt(shared_secret[:16], padded) + mac = compute_mac(shared_secret, ciphertext) + return mac + ciphertext + + +# Silence unused-import linter for `Any` — used only in type annotations +__all__: list[str] = [ + "aes_ecb_encrypt", + "build_advert_payload", + "channel_count", + "channel_hash", + "channel_secret_from_hashtag", + "compute_mac", + "ecdh_shared_secret", + "get_channel_secret", + "get_channels_by_hash", + "grp_encrypt", + "grp_verify_and_decrypt", + "init_channels", + "init_identity", + "my_hash", + "parse_grp_plaintext", + "parse_peer_plaintext", + "peer_encrypt", + "peer_verify_and_decrypt", + "pubkey_bytes", + "raw_peer_encrypt", + "register_channel", + "sign", + "verify_advert_signature", +] diff --git a/orac/meshcore.py b/orac/meshcore.py new file mode 100644 index 0000000..5aaf5db --- /dev/null +++ b/orac/meshcore.py @@ -0,0 +1,453 @@ +"""MeshCore packet parsing/building, routing, dedup, node registry, ADVERT, ACK, PATH.""" + +from __future__ import annotations + +import hashlib +import logging +import os +import struct +import time + +from orac.constants import ( + DEDUP_TTL, + DM_DEDUP_TTL, + PENDING_DM_TTL, + RESP_SERVER_LOGIN_OK, +) +from orac.crypto import ( + build_advert_payload, + ecdh_shared_secret, + grp_encrypt, + my_hash, + peer_encrypt, + raw_peer_encrypt, + verify_advert_signature, +) + +log = logging.getLogger("orac") + +# ── Route table ────────────────────────────────────────────────── +# src_hash (int) -> (reversed_path_hops: list[bytes], hash_size: int) + +_route_table: dict[int, tuple[list[bytes], int]] = {} + +_RT_NAMES: dict[int, str] = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"} + + +def learn_route(src_hash: int, path_hops: list[bytes], hash_size: int) -> None: + """Learn a return route from an incoming packet's path. + + path_hops is ordered closest-relay-first as received. + We reverse it so our outgoing path goes through the same relays + back toward the sender. Prefers larger hash sizes (3 > 2 > 1). + """ + existing = _route_table.get(src_hash) + if existing is not None: + _, existing_hs = existing + if hash_size < existing_hs: + return # Don't downgrade hash size + _route_table[src_hash] = (list(reversed(path_hops)), hash_size) + + +def get_route(dest_hash: int) -> tuple[list[bytes], int] | None: + """Get a learned return route for a destination, or None if unknown.""" + return _route_table.get(dest_hash) + + +def route_name(route_type: int) -> str: + """Human-readable name for a route type.""" + return _RT_NAMES.get(route_type, f"rt{route_type}") + + +# ── Node registry ──────────────────────────────────────────────── +# Stored in state module; these helpers bridge to it. + +from orac import state as _state_mod # noqa: E402 + + +def register_node(pubkey: bytes, name: str) -> bool: + """Register a node. Returns True if this is a NEW node.""" + return _state_mod.register_node(pubkey, name) + + +def lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]: + """Find all known nodes whose pubkey first byte matches.""" + return _state_mod.lookup_node_by_hash(hash_byte) + + +def node_name(pubkey_hex: str) -> str: + """Human-readable name for a node, or truncated hex.""" + return _state_mod.node_name(pubkey_hex) + + +# ── Deduplication ──────────────────────────────────────────────── + +_seen_packets: dict[bytes, float] = {} +_seen_dm_texts: dict[str, float] = {} # "pubkey_hex:text" -> monotonic timestamp + + +def is_duplicate(payload_type: int, payload: bytes) -> bool: + """Dedup via 8-byte packet hash (spec Section 16).""" + now = time.monotonic() + if len(_seen_packets) > 500: + expired = [k for k, t in _seen_packets.items() if now - t > DEDUP_TTL] + for k in expired: + del _seen_packets[k] + + pkt_hash = hashlib.sha256(bytes([payload_type]) + payload).digest()[:8] + if pkt_hash in _seen_packets and now - _seen_packets[pkt_hash] < DEDUP_TTL: + return True + _seen_packets[pkt_hash] = now + return False + + +def is_dm_duplicate(peer_pubkey_hex: str, text: str) -> bool: + """Dedup on decrypted DM text (catches retries with different attempt counters).""" + now = time.monotonic() + if len(_seen_dm_texts) > 200: + expired = [k for k, t in _seen_dm_texts.items() if now - t > DM_DEDUP_TTL] + for k in expired: + del _seen_dm_texts[k] + + key = f"{peer_pubkey_hex}:{text}" + if key in _seen_dm_texts and now - _seen_dm_texts[key] < DM_DEDUP_TTL: + return True + _seen_dm_texts[key] = now + return False + + +# ── Pending DMs from unknown senders ───────────────────────────── + +_pending_dms: dict[int, list[tuple[bytes, float]]] = {} + + +def queue_pending_dm(src_hash: int, raw_payload: bytes) -> bool: + """Queue a DM from an unknown sender. Returns True if this is the first queued.""" + _pending_dms.setdefault(src_hash, []).append((raw_payload, time.monotonic())) + return len(_pending_dms[src_hash]) == 1 + + +def pop_pending_dms(node_hash: int) -> list[tuple[bytes, float]]: + """Pop and return all pending DMs for a node hash.""" + return _pending_dms.pop(node_hash, []) + + +def has_pending_dms(node_hash: int) -> bool: + """Check if there are pending DMs for a node hash.""" + return bool(_pending_dms.get(node_hash)) + + +def is_pending_expired(timestamp: float) -> bool: + """Check if a pending DM timestamp is expired.""" + return time.monotonic() - timestamp > PENDING_DM_TTL + + +# ── Packet parsing ─────────────────────────────────────────────── + + +def parse_header_and_path( + packet: bytes, +) -> tuple[int, int, int, int, bytes, list[bytes], int] | None: + """Parse MeshCore header + path. + + Returns (payload_type, route_type, payload_ver, pos, payload, path_hops, hash_size) + or None. path_hops is a list of raw hash bytes for each hop (closest relay first). + """ + if len(packet) < 3: + return None + + header = packet[0] + route_type = header & 0x03 + payload_type = (header >> 2) & 0x0F + payload_ver = (header >> 6) & 0x03 + + if payload_ver != 0: + return None + + has_tc = route_type in (0, 3) + + for skip_tc in [True, False] if has_tc else [False]: + pos = 5 if skip_tc else 1 + if pos >= len(packet): + continue + + path_len_byte = packet[pos] + pos += 1 + hash_size_code = path_len_byte >> 6 + if hash_size_code == 3: + continue + hash_size = hash_size_code + 1 + hop_count = path_len_byte & 0x3F + path_bytes = hop_count * hash_size + if path_bytes > 64 or pos + path_bytes > len(packet): + continue + + hops: list[bytes] = [] + for i in range(hop_count): + hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size]) + pos += path_bytes + + payload = packet[pos:] + if payload and len(payload) <= 184: + return payload_type, route_type, payload_ver, pos, payload, hops, hash_size + + return None + + +# ── Packet building ────────────────────────────────────────────── + + +def _build_routed_packet(payload_type: int, payload: bytes, dest_hash: int) -> bytes: + """Build a packet using a learned route if available, otherwise flood.""" + route = get_route(dest_hash) + if route is not None: + hops, hash_size = route + hash_size_code = hash_size - 1 + path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F) + path_data = b"".join(hops) + header = bytes([(payload_type << 2) | 2]) # direct route_type = 2 + return header + bytes([path_len_byte]) + path_data + payload + else: + header = bytes([(payload_type << 2) | 1]) # flood route_type = 1 + path_len = bytes([0x40]) # 0 hops, 2-byte hash mode + return header + path_len + payload + + +def build_grp_packet(channel_payload: bytes) -> bytes: + """Wrap channel payload in a GRP_TXT flood packet.""" + header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1 + path_len = bytes([0x40]) # 0 hops, 2-byte hash mode + return header + path_len + channel_payload + + +def build_advert_packet() -> bytes: + """Build a MeshCore ADVERT packet for this node.""" + advert_payload = build_advert_payload() + header = bytes([0x11]) # ADVERT flood: (4 << 2) | 1 + path_len = bytes([0x40]) # 0 hops, 2-byte hash mode + return header + path_len + advert_payload + + +def build_dm_packet(peer_pubkey: bytes, text: str) -> bytes: + """Build a TXT_MSG DM packet using learned route or flood.""" + shared_secret = ecdh_shared_secret(peer_pubkey) + mac_ct = peer_encrypt(shared_secret, text) + dm_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct + return _build_routed_packet(0x02, dm_payload, peer_pubkey[0]) + + +def build_ack_packet(ack_crc: bytes, dest_hash: int) -> bytes: + """Build an ACK packet using learned route or flood.""" + return _build_routed_packet(0x03, ack_crc, dest_hash) + + +def build_path_return_packet(peer_pubkey: bytes, path_hops: list[bytes], hash_size: int) -> bytes: + """Build a PATH return (0x08) so the sender can learn a direct route to us.""" + reversed_hops = list(reversed(path_hops)) + hash_size_code = hash_size - 1 + path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F) + path_data = b"".join(reversed_hops) + + # Inner plaintext: path_len + path + 0xFF (dummy extra) + 4 random bytes + inner = bytes([path_len_byte]) + path_data + b"\xff" + os.urandom(4) + + shared_secret = ecdh_shared_secret(peer_pubkey) + mac_ct = raw_peer_encrypt(shared_secret, inner) + + # Outer: dest_hash + src_hash + mac + ciphertext + path_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct + return _build_routed_packet(0x08, path_payload, peer_pubkey[0]) + + +def build_login_response_packet(peer_pubkey: bytes) -> bytes: + """Build a RESPONSE packet (login OK) back to the peer.""" + shared_secret = ecdh_shared_secret(peer_pubkey) + + # Response payload: timestamp(4) + RESP_SERVER_LOGIN_OK(1) + zero(1) + permissions(2) + reply = struct.pack(" bytes | None: + """Build a complete GRP_TXT packet for a channel. Returns None if channel unknown.""" + from orac.crypto import get_channel_secret + + secret = get_channel_secret(channel_name) + if secret is None: + return None + payload = grp_encrypt(secret, sender, text) + return build_grp_packet(payload) + + +# ── ACK hash computation ──────────────────────────────────────── + + +def compute_ack_hash(plaintext: bytes, sender_pubkey: bytes) -> bytes: + """Compute 4-byte ACK hash per MeshCore BaseChatMesh.cpp. + + Hash = SHA-256(frag1 || sender_pubkey)[0:4] + frag1 = timestamp(4) + txt_type_attempt(1) + text (no null terminator) + """ + text_bytes = plaintext[5:] + null_pos = text_bytes.find(b"\x00") + text_len = null_pos if null_pos >= 0 else len(text_bytes) + frag1 = plaintext[: 5 + text_len] + return hashlib.sha256(frag1 + sender_pubkey).digest()[:4] + + +# ── GRP_TXT interception ──────────────────────────────────────── + + +def try_decrypt_grp(raw_payload: bytes) -> tuple[str, str] | None: + """Try to decrypt a GRP_TXT payload. Returns (channel_name, text) or None.""" + from orac.crypto import get_channels_by_hash, grp_verify_and_decrypt, parse_grp_plaintext + + if len(raw_payload) < 19: + return None + ch = raw_payload[0] + mac_bytes = raw_payload[1:3] + ciphertext = raw_payload[3:] + + candidates = get_channels_by_hash(ch) + for chan_name, secret in candidates: + plaintext = grp_verify_and_decrypt(secret, mac_bytes, ciphertext) + if plaintext is not None: + parsed = parse_grp_plaintext(plaintext) + if parsed: + _, text = parsed + return chan_name, text + return None + + +# ── DM interception ────────────────────────────────────────────── + + +def try_decrypt_dm(raw_payload: bytes) -> tuple[bytes, str, str, bytes] | None: + """Try to decrypt a TXT_MSG DM addressed to us. + + Returns (sender_pubkey, sender_name, message_text, raw_plaintext) or None. + """ + from orac.crypto import parse_peer_plaintext, peer_verify_and_decrypt + + if len(raw_payload) < 20: + return None + + dest_hash = raw_payload[0] + src_hash = raw_payload[1] + mac_bytes = raw_payload[2:4] + ciphertext = raw_payload[4:] + + log.debug( + "TXT_MSG dest=0x%02x src=0x%02x me=0x%02x [%dB]", + dest_hash, + src_hash, + my_hash(), + len(ciphertext), + ) + + if dest_hash != my_hash(): + return None + + candidates = lookup_node_by_hash(src_hash) + if not candidates: + log.warning("DM for us but no known node with hash 0x%02x", src_hash) + return None + + for peer_pubkey, peer_name in candidates: + try: + shared_secret = ecdh_shared_secret(peer_pubkey) + plaintext = peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext) + if plaintext is not None: + text = parse_peer_plaintext(plaintext) + if text: + return peer_pubkey, peer_name, text, plaintext + except Exception: + pk_hex = peer_pubkey.hex() + log.warning("Evicting bad key for %s (%s...)", peer_name, pk_hex[:16]) + _state_mod.evict_node(pk_hex) + continue + + return None + + +def try_decrypt_anon_req( + raw_payload: bytes, +) -> tuple[bytes, str, bytes | None] | None: + """Try to decrypt an ANON_REQ (login). + + Returns (sender_pubkey, sender_name, plaintext_or_None) or None. + """ + from orac.crypto import peer_verify_and_decrypt + + if len(raw_payload) < 51: + return None + + dest_hash = raw_payload[0] + sender_pubkey = raw_payload[1:33] + mac_bytes = raw_payload[33:35] + ciphertext = raw_payload[35:] + + log.info( + "ANON_REQ dest=0x%02x sender=%s... [%dB]", + dest_hash, + sender_pubkey.hex()[:16], + len(ciphertext), + ) + + if dest_hash != my_hash(): + return None + + # Register sender -- we now have their full pubkey + peer_name = node_name(sender_pubkey.hex()) + is_new = register_node(sender_pubkey, peer_name) + if is_new: + log.info( + "Registered new peer from ANON_REQ: %s (hash=0x%02x)", + peer_name, + sender_pubkey[0], + ) + + try: + shared_secret = ecdh_shared_secret(sender_pubkey) + plaintext = peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext) + if plaintext is not None: + return sender_pubkey, peer_name, plaintext + else: + log.warning("ANON_REQ MAC mismatch") + except Exception as e: + log.error("ANON_REQ decrypt error: %s", e) + + return sender_pubkey, peer_name, None + + +def try_decode_advert(raw_payload: bytes) -> tuple[bytes, str] | None: + """Decode and verify an ADVERT payload. Returns (pubkey, name) or None.""" + if len(raw_payload) < 100: + return None + + pubkey = raw_payload[0:32] + timestamp = raw_payload[32:36] + signature = raw_payload[36:100] + app_data = raw_payload[100:] + + if not verify_advert_signature(pubkey, timestamp, app_data, signature): + return None + + name = "" + if app_data: + flags = app_data[0] + pos = 1 + if flags & 0x10: # has location + pos += 8 + if flags & 0x20: # feat1 + pos += 2 + if flags & 0x40: # feat2 + pos += 2 + if flags & 0x80 and pos < len(app_data): # has name + name = app_data[pos:].decode("utf-8", errors="replace") + + return pubkey, name if name else pubkey.hex()[:8] diff --git a/orac/state.py b/orac/state.py new file mode 100644 index 0000000..a4e9c66 --- /dev/null +++ b/orac/state.py @@ -0,0 +1,132 @@ +"""Persistent state (JSON), history tracking, rate limiting.""" + +from __future__ import annotations + +import json +import logging +import time + +from orac.constants import ( + CHANNEL_HISTORY_SIZE, + DATA_DIR, + DM_HISTORY_SIZE, + STATE_FILE, +) + +log = logging.getLogger("orac") + +# ── Persistent state ───────────────────────────────────────────── + +_state: dict[str, dict[str, object]] = { + "channel_history": {}, # channel_name -> list of messages + "dm_history": {}, # peer_pubkey_hex -> list of messages + "known_nodes": {}, # pubkey_hex -> {"name": str, "seen": float} +} + + +def load_state() -> None: + """Load persisted state from disk.""" + global _state + if STATE_FILE.is_file(): + try: + with open(STATE_FILE) as f: + loaded = json.load(f) + for key in _state: + if key in loaded: + _state[key] = loaded[key] + log.info("Loaded state from %s", STATE_FILE) + except Exception as e: + log.warning("Failed to load state: %s", e) + + +def save_state() -> None: + """Persist state to disk (atomic write via tmp + rename).""" + try: + DATA_DIR.mkdir(parents=True, exist_ok=True) + tmp = STATE_FILE.with_suffix(".tmp") + with open(tmp, "w") as f: + json.dump(_state, f, indent=2) + tmp.rename(STATE_FILE) + except Exception as e: + log.error("Failed to save state: %s", e) + + +# ── Node registry (state-backed) ──────────────────────────────── + + +def register_node(pubkey: bytes, name: str) -> bool: + """Register a node. Returns True if this is a NEW node (not just an update).""" + pk_hex = pubkey.hex() + is_new = pk_hex not in _state["known_nodes"] + _state["known_nodes"][pk_hex] = {"name": name, "seen": time.time()} # type: ignore[index] + save_state() + return is_new + + +def lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]: + """Find all known nodes whose pubkey first byte matches. + + Never expires keys -- once we learn a peer's pubkey, we can always decrypt + their DMs. Peers shouldn't need to re-advertise just to message the bot. + """ + results: list[tuple[bytes, str]] = [] + for pk_hex, info in _state["known_nodes"].items(): # type: ignore[union-attr] + pk_bytes = bytes.fromhex(pk_hex) + if pk_bytes[0] == hash_byte: + results.append((pk_bytes, info["name"])) # type: ignore[index] + return results + + +def node_name(pubkey_hex: str) -> str: + """Human-readable name for a node, or truncated hex.""" + info = _state["known_nodes"].get(pubkey_hex) # type: ignore[union-attr] + return info["name"] if info else pubkey_hex[:8] # type: ignore[index] + + +def evict_node(pubkey_hex: str) -> None: + """Remove a node from the registry (e.g., bad key).""" + _state["known_nodes"].pop(pubkey_hex, None) # type: ignore[union-attr] + save_state() + + +def known_node_count() -> int: + """Number of known nodes.""" + return len(_state["known_nodes"]) + + +# ── Channel history ────────────────────────────────────────────── + + +def record_channel_msg(channel: str, text: str) -> None: + """Append a message to channel history and persist.""" + hist = _state["channel_history"] + if channel not in hist: # type: ignore[operator] + hist[channel] = [] # type: ignore[index] + hist[channel].append(text) # type: ignore[index] + if len(hist[channel]) > CHANNEL_HISTORY_SIZE: # type: ignore[index] + hist[channel] = hist[channel][-CHANNEL_HISTORY_SIZE:] # type: ignore[index] + save_state() + + +def get_channel_history(channel: str) -> list[str]: + """Get recent channel history.""" + return list(_state["channel_history"].get(channel, [])) # type: ignore[union-attr] + + +# ── DM history ─────────────────────────────────────────────────── + + +def record_dm_msg(peer_pubkey_hex: str, text: str) -> None: + """Append a message to DM history and persist.""" + hist = _state["dm_history"] + if peer_pubkey_hex not in hist: # type: ignore[operator] + hist[peer_pubkey_hex] = [] # type: ignore[index] + hist[peer_pubkey_hex].append(text) # type: ignore[index] + if len(hist[peer_pubkey_hex]) > DM_HISTORY_SIZE: # type: ignore[index] + hist[peer_pubkey_hex] = hist[peer_pubkey_hex][-DM_HISTORY_SIZE:] # type: ignore[index] + save_state() + + +def get_dm_history(peer_pubkey_hex: str) -> list[str]: + """Get recent DM history for a peer.""" + return list(_state["dm_history"].get(peer_pubkey_hex, [])) # type: ignore[union-attr] diff --git a/pyproject.toml b/pyproject.toml index 8186e09..cec528c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ license = "MIT" requires-python = ">=3.10" authors = [{ name = "Stephen Waits" }] dependencies = [ - "donglora-python", + "donglora", "anthropic", "cobs", "pyserial", @@ -19,3 +19,17 @@ dev = ["ruff"] [tool.uv] package = false + +[tool.uv.sources] +donglora = { path = "../client-py", editable = true } + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "UP", "B", "C4", "ARG", "SIM", "RUF"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["donglora", "orac"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bc8f3cc --- /dev/null +++ b/uv.lock @@ -0,0 +1,658 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anthropic" +version = "0.92.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/2d/fc5c5a369db977efbaa646d77ba42b38a6de4e95789884032b0e2e3fc834/anthropic-0.92.0.tar.gz", hash = "sha256:d1e792ed0692379452a1af6b266df495e973c3695cd0aace2a108b838393cbc4", size = 652420, upload-time = "2026-04-08T16:55:35.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/21/bf5b5ab10b6932c5c43eaa66b6e3f256de569cf0323d89f9cc281a0d0f39/anthropic-0.92.0-py3-none-any.whl", hash = "sha256:f92a4bd065d5cab90a96b65bb44e473bf7c6fe731a743cd156e9ad1d245c381e", size = 621195, upload-time = "2026-04-08T16:55:33.639Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cobs" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/ef/ea149311227a4fc3160cc885fce06da7c7d76782a308ef070b8065c69953/cobs-1.2.2.tar.gz", hash = "sha256:dbdd5e32111d72786f83d0c269215dcd6ac629b1ac1962c6878221f3b2ca98da", size = 14582, upload-time = "2025-07-20T01:08:35.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e0/9574590639a624a9a84c89a569f36343523a306fff2ffddc9468a2b9b05d/cobs-1.2.2-cp312-cp312-win32.whl", hash = "sha256:3afe0cec5d91164f84dcf35b471949aa36285664d21ee984350e39d485707d11", size = 26400, upload-time = "2025-07-20T01:08:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4f/5418d9decdc4cf27a814fc189bc4fa44d216d37b871d633654f37ed331f8/cobs-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:4da6048f6e16cbd6c8ec5e95e8520e226248ffb8d31e9d6fc5e0888b3b65c54b", size = 27776, upload-time = "2025-07-20T01:08:31.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/df/7f07559954d53d09680aa4f5c72db999e7643253e2ca4e431023fe5c1c40/cobs-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a8fdf6ca70247e7271caea6f2f6f9c0343f5e872e2fd47e21f71831fa9ababa8", size = 26401, upload-time = "2025-07-20T01:08:32.508Z" }, + { url = "https://files.pythonhosted.org/packages/0c/00/5b063105ff567bb7fb72c3cd3e96116a3889381fa2c0e7747497391dbecc/cobs-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe3f7194d5fbbc02e8fb03d26ad626a33fa0d420b50d66ed60ac4bf516322d8e", size = 27774, upload-time = "2025-07-20T01:08:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/78/35/de1e889c26fcfc9ae6852b8f99e744e6690a3baae99f63e2b9fffdd21710/cobs-1.2.2-cp314-cp314-win32.whl", hash = "sha256:bcf08cb0b68c1824a87cfa89e0684d5a3eaee9b9e08b02ce4a54d5589536f01c", size = 26779, upload-time = "2026-03-09T02:18:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/b6/64/390e226843cc83066acc60257e0e71ebdbce6dd915ec4f3d34101b9ac31b/cobs-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1f27b7a553d809487287e4ed88540b2c30ed9c9bb596173bfcf549e705af7bf0", size = 28205, upload-time = "2026-03-09T02:18:20.193Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "donglora" +version = "0.2.0" +source = { editable = "../client-py" } +dependencies = [ + { name = "cobs" }, + { name = "pyserial" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", marker = "extra == 'orac'" }, + { name = "cobs" }, + { name = "pycryptodome", marker = "extra == 'meshcore'" }, + { name = "pycryptodome", marker = "extra == 'orac'" }, + { name = "pynacl", marker = "extra == 'meshcore'" }, + { name = "pynacl", marker = "extra == 'orac'" }, + { name = "pyserial" }, +] +provides-extras = ["meshcore", "orac"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[[package]] +name = "donglora-ai-bot" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "anthropic" }, + { name = "cobs" }, + { name = "donglora" }, + { name = "pycryptodome" }, + { name = "pynacl" }, + { name = "pyserial" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic" }, + { name = "cobs" }, + { name = "donglora", editable = "../client-py" }, + { name = "pycryptodome" }, + { name = "pynacl" }, + { name = "pyserial" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "ruff" }] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" }, + { url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" }, + { url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" }, + { url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" }, + { url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" }, + { url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" }, + { url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" }, + { url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" }, + { url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" }, + { url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +]