diff --git a/apps/Cortensor-AIOracle/.env.example b/apps/Cortensor-AIOracle/.env.example deleted file mode 100644 index 2d16dcf..0000000 --- a/apps/Cortensor-AIOracle/.env.example +++ /dev/null @@ -1,105 +0,0 @@ -# ============================================================================= -# AI ORACLE - Environment Configuration -# ============================================================================= - -# ----------------------------------------------------------------------------- -# Cortensor Network (REQUIRED) -# ----------------------------------------------------------------------------- -# Cortensor Router API - Main AI network endpoint -CORTENSOR_ROUTER_URL=http://your-cortensor-host:5010 -CORTENSOR_API_KEY=your_cortensor_api_key_here -NEXT_PUBLIC_CORTENSOR_COMPLETIONS_URL=http://your-cortensor-host:5010/api/v1/completions - -# WebSocket endpoint for real-time updates -NEXT_PUBLIC_CORTENSOR_WS_URL=ws://your-cortensor-host:9004 - -# Default session ID (Deepseek R1 Distill Llama 8B Q4) -NEXT_PUBLIC_DEEPSEEK_SESSION_ID=5 - -# ----------------------------------------------------------------------------- -# Tavily AI Search (REQUIRED for Sources) -# ----------------------------------------------------------------------------- -# Primary source for fact-checking and real-time data -# Sign up at: https://tavily.com -# Free tier: 1,000 API calls/month -TAVILY_API_KEY=tvly-your_tavily_api_key_here - -# ----------------------------------------------------------------------------- -# UI Customization -# ----------------------------------------------------------------------------- -NEXT_PUBLIC_APP_NAME=AI Oracle -NEXT_PUBLIC_APP_VERSION=Truth Machine v1.0 - -# ----------------------------------------------------------------------------- -# Oracle Configuration -# ----------------------------------------------------------------------------- -ORACLE_MIN_CONSENSUS=3 -ORACLE_CONFIDENCE_THRESHOLD=0.8 -ORACLE_DEBUG_LOGS=1 -NEXT_PUBLIC_USE_WS=true - -# LLM Parameters -NEXT_PUBLIC_MAX_INPUT_LENGTH=2000 -LLM_MAX_TOKENS=4096 -LLM_TIMEOUT=300 -MODEL_NAME=Deepseek R1 -PROMPT_TYPE=1 -PRECOMMIT_TIMEOUT=90 - -# ----------------------------------------------------------------------------- -# Multi-Session Model Configuration (Optional) -# ----------------------------------------------------------------------------- -# LLaVA 1.5 7B Q4 -SESSION_LLAVA_15_7B_Q4=3 -WS_LLAVA_15_7B_Q4=ws://your-cortensor-host:9002 - -# LLaMA 3.1 8B Q4 -SESSION_LLAMA_31_8B_Q4=4 -WS_LLAMA_31_8B_Q4=ws://your-cortensor-host:9003 - -# DeepSeek R1 Distill Llama 8B Q4 (Default) -SESSION_DEEPSEEK_R1_8B_Q4=5 -WS_DEEPSEEK_R1_8B_Q4=ws://your-cortensor-host:9004 - -# LLaMA 3.1 8B Q4 CPU -SESSION_LLAMA_31_8B_Q4_CPU=5 -WS_LLAMA_31_8B_Q4_CPU=ws://your-cortensor-host:9004 - -# ----------------------------------------------------------------------------- -# Blockchain/Web3 (Optional) -# ----------------------------------------------------------------------------- -NEXT_PUBLIC_SESSION_V2_ADDRESS=your_session_v2_contract_address -NEXT_PUBLIC_SESSION_QUEUE_V2_ADDRESS=your_session_queue_v2_contract_address - -# ----------------------------------------------------------------------------- -# Legacy External APIs (NOT USED - Replaced by Tavily) -# ----------------------------------------------------------------------------- -# These are kept for backward compatibility but no longer used -# The system now uses Tavily AI Search for all external data sources - -# NEWS_API_KEY=not_used -# WEATHER_API_KEY=not_used -# ALPHA_VANTAGE_API_KEY=not_used -# GUARDIAN_API_KEY=not_used -# CURRENTS_API_KEY=not_used -# GOOGLE_FACTCHECK_API_KEY=not_used -# COINGECKO_API_KEY=not_used -# FASTFOREX_API_KEY=not_used - -# ----------------------------------------------------------------------------- -# Notes -# ----------------------------------------------------------------------------- -# REQUIRED APIs: -# 1. CORTENSOR_API_KEY - Get from Cortensor Network -# 2. TAVILY_API_KEY - Get from https://tavily.com (Free: 1,000 calls/month) -# -# All other APIs are optional or deprecated. -# The system will work with just Cortensor + Tavily. -# -# Tavily provides: -# - Real-time web search -# - Fact-checking sources -# - High-credibility news -# - Automatic source ranking -# - All in one API call -# ============================================================================= diff --git a/apps/Cortensor-AIOracle/README.md b/apps/Cortensor-AIOracle/README.md index 6f0a230..e167ff2 100644 --- a/apps/Cortensor-AIOracle/README.md +++ b/apps/Cortensor-AIOracle/README.md @@ -60,6 +60,12 @@ Cortensor-AIOracle is an AI-powered oracle that uses a consensus mechanism acros cp .env.example .env ``` + ### Oracle Facts Storage + + Oracle facts are stored in a SQLite database (and imported once from `data/oracle-facts.json` if present). + + - `ORACLE_FACTS_DB_PATH` (optional): Path to the SQLite file. Defaults to `data/oracle-facts.sqlite`. + ## Usage Once the configuration is complete, run the development server: diff --git a/apps/Cortensor-AIOracle/package.json b/apps/Cortensor-AIOracle/package.json index b850c69..2ec7f1a 100644 --- a/apps/Cortensor-AIOracle/package.json +++ b/apps/Cortensor-AIOracle/package.json @@ -39,7 +39,9 @@ "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "latest", "@tailwindcss/typography": "^0.5.16", + "@types/better-sqlite3": "^7.6.13", "autoprefixer": "^10.4.20", + "better-sqlite3": "^12.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", diff --git a/apps/Cortensor-AIOracle/pnpm-lock.yaml b/apps/Cortensor-AIOracle/pnpm-lock.yaml index 9dcd8e5..08b299b 100644 --- a/apps/Cortensor-AIOracle/pnpm-lock.yaml +++ b/apps/Cortensor-AIOracle/pnpm-lock.yaml @@ -95,9 +95,15 @@ importers: '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.16(tailwindcss@3.4.17) + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.6) + better-sqlite3: + specifier: ^12.5.0 + version: 12.5.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1290,6 +1296,9 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -1398,10 +1407,23 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.5.0: + resolution: {integrity: sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -1414,6 +1436,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -1443,6 +1468,9 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1547,6 +1575,14 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1595,6 +1631,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1609,6 +1648,10 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1623,6 +1666,9 @@ packages: fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1648,6 +1694,9 @@ packages: react-dom: optional: true + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1660,6 +1709,9 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1695,6 +1747,15 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} @@ -1938,14 +1999,24 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + motion-dom@11.18.1: resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} @@ -1963,6 +2034,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -1990,6 +2064,10 @@ packages: sass: optional: true + node-abi@3.85.0: + resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + engines: {node: '>=10'} + node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} @@ -2009,6 +2087,9 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2090,15 +2171,27 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.1.1: resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} peerDependencies: @@ -2177,6 +2270,10 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2218,6 +2315,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -2242,6 +2342,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sonner@1.7.4: resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} peerDependencies: @@ -2263,6 +2369,9 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2274,6 +2383,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + style-to-js@1.1.17: resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==} @@ -2315,6 +2428,13 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2341,6 +2461,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -2432,6 +2555,9 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yaml@2.8.1: resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} engines: {node: '>= 14.6'} @@ -3500,6 +3626,10 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.17 + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 22.17.1 + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -3599,8 +3729,25 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + + better-sqlite3@12.5.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + binary-extensions@2.3.0: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -3616,6 +3763,11 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.2) + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + camelcase-css@2.0.1: {} caniuse-lite@1.0.30001734: {} @@ -3644,6 +3796,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chownr@1.1.4: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -3734,10 +3888,15 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + dequal@2.0.3: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -3774,6 +3933,10 @@ snapshots: emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + escalade@3.2.0: {} escape-string-regexp@5.0.0: {} @@ -3782,6 +3945,8 @@ snapshots: eventemitter3@4.0.7: {} + expand-template@2.0.3: {} + extend@3.0.2: {} fast-equals@5.2.2: {} @@ -3798,6 +3963,8 @@ snapshots: dependencies: reusify: 1.1.0 + file-uri-to-path@1.0.0: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3818,6 +3985,8 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) + fs-constants@1.0.0: {} + fsevents@2.3.3: optional: true @@ -3825,6 +3994,8 @@ snapshots: get-nonce@1.0.1: {} + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3885,6 +4056,12 @@ snapshots: html-url-attributes@3.0.1: {} + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + inline-style-parser@0.2.4: {} input-otp@1.4.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1): @@ -4320,12 +4497,18 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mimic-response@3.1.0: {} + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + minipass@7.1.2: {} + mkdirp-classic@0.5.3: {} + motion-dom@11.18.1: dependencies: motion-utils: 11.18.1 @@ -4342,6 +4525,8 @@ snapshots: nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + next-themes@0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: react: 19.1.1 @@ -4370,6 +4555,10 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abi@3.85.0: + dependencies: + semver: 7.7.3 + node-releases@2.0.19: {} normalize-path@3.0.0: {} @@ -4380,6 +4569,10 @@ snapshots: object-hash@3.0.0: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + package-json-from-dist@1.0.1: {} parse-entities@4.0.2: @@ -4457,6 +4650,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.85.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -4465,8 +4673,20 @@ snapshots: property-information@7.1.0: {} + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + queue-microtask@1.2.3: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.1.1(react@19.1.1): dependencies: react: 19.1.1 @@ -4553,6 +4773,12 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -4628,10 +4854,11 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} + scheduler@0.26.0: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} sharp@0.34.5: dependencies: @@ -4673,6 +4900,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sonner@1.7.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: react: 19.1.1 @@ -4694,6 +4929,10 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -4707,6 +4946,8 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-json-comments@2.0.1: {} + style-to-js@1.1.17: dependencies: style-to-object: 1.0.9 @@ -4765,6 +5006,21 @@ snapshots: transitivePeerDependencies: - ts-node + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -4787,6 +5043,10 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + typescript@5.9.2: {} undici-types@6.21.0: {} @@ -4908,6 +5168,8 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + wrappy@1.0.2: {} + yaml@2.8.1: {} zod@3.25.76: {} diff --git a/apps/Cortensor-AIOracle/src/app/api/oracle-facts/route.ts b/apps/Cortensor-AIOracle/src/app/api/oracle-facts/route.ts index 05b42f1..dd01454 100644 --- a/apps/Cortensor-AIOracle/src/app/api/oracle-facts/route.ts +++ b/apps/Cortensor-AIOracle/src/app/api/oracle-facts/route.ts @@ -3,6 +3,7 @@ import { addOracleFact, getOracleFacts } from '@/lib/oracle-facts' export const revalidate = 0 export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' export async function GET(request: NextRequest) { try { diff --git a/apps/Cortensor-AIOracle/src/app/api/oracle/stream/route.ts b/apps/Cortensor-AIOracle/src/app/api/oracle/stream/route.ts index 80abc2f..dc52624 100644 --- a/apps/Cortensor-AIOracle/src/app/api/oracle/stream/route.ts +++ b/apps/Cortensor-AIOracle/src/app/api/oracle/stream/route.ts @@ -9,7 +9,7 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url) const query = searchParams.get('query') || '' const sessionId = searchParams.get('sessionId') || String(CORTENSOR_CONFIG.SESSION_ID) - const modelId = searchParams.get('modelId') || 'deepseek-r1' + const modelId = searchParams.get('modelId') || 'gpt-oss-20b' const miners = Math.max(3, Math.min(parseInt(searchParams.get('miners') || '5', 10), 15)) const temperature = parseFloat(searchParams.get('temperature') || '0.7') const topK = parseInt(searchParams.get('topK') || '40', 10) diff --git a/apps/Cortensor-AIOracle/src/app/page.tsx b/apps/Cortensor-AIOracle/src/app/page.tsx index 7326e3e..568bb27 100644 --- a/apps/Cortensor-AIOracle/src/app/page.tsx +++ b/apps/Cortensor-AIOracle/src/app/page.tsx @@ -11,6 +11,7 @@ import { RealtimeLoading } from '../components/realtime-loading' import { QueryHistory } from '../components/query-history' import { AVAILABLE_MODELS, ModelConfig } from '../lib/models' import type { OracleFact } from '../lib/oracle-facts' +import { Skeleton } from '../components/ui/skeleton' const USER_ID_STORAGE_KEY = 'ai-oracle-user-id' @@ -84,6 +85,7 @@ export default function HomePage() { const [loadingComplete, setLoadingComplete] = useState(false) const [results, setResults] = useState(null) const [queryHistory, setQueryHistory] = useState([]) + const [isHistoryHydrated, setIsHistoryHydrated] = useState(false) const [randomFact, setRandomFact] = useState(null) const [isRandomFactLoading, setIsRandomFactLoading] = useState(false) const [randomFactError, setRandomFactError] = useState('') @@ -133,6 +135,7 @@ export default function HomePage() { console.error('Error loading query history:', error) } } + setIsHistoryHydrated(true) }, []) useEffect(() => { @@ -283,6 +286,19 @@ export default function HomePage() { setResults(null) try { + const readJsonResponse = async (res: Response) => { + const contentType = res.headers.get('content-type') || '' + if (contentType.toLowerCase().includes('application/json')) { + return await res.json() + } + const text = await res.text() + const snippet = text.replace(/\s+/g, ' ').trim().slice(0, 220) + throw new Error( + `API returned non-JSON response (status ${res.status}). ` + + (snippet ? `Body starts with: ${snippet}` : 'Empty response body') + ) + } + const activeUserId = ensureUserId() const clientReference = activeUserId ? `user-oracle-${activeUserId.replace(/^usr-/, '')}` : undefined // Direct submission - no WebSocket wait @@ -297,10 +313,16 @@ export default function HomePage() { topK: selectedModel.topK, topP: selectedModel.topP, clientReference - }) + }), + cache: 'no-store' }) - - const data = await response.json() + + const data = await readJsonResponse(response) + if (!response.ok) { + const msg = (data && (data.error || data.message)) || response.statusText || 'Request failed' + throw new Error(msg) + } + const responseData = data.data || data const effectiveClientReference = responseData.clientReference || clientReference @@ -317,7 +339,7 @@ export default function HomePage() { // Fetch detailed task information using the taskId const taskResponse = await fetch(`/api/tasks/${taskId}`) if (taskResponse.ok) { - realMinerData = await taskResponse.json() + realMinerData = await readJsonResponse(taskResponse) console.log('Task details fetched:', realMinerData) } } catch (err) { @@ -608,7 +630,16 @@ export default function HomePage() { {/* Query History */}
- {queryHistory.length === 0 ? ( + {!isHistoryHydrated ? ( +
+
+ + + + +
+
+ ) : queryHistory.length === 0 ? (

No queries yet. Ask the Oracle to see history.

) : ( @@ -825,7 +856,7 @@ export default function HomePage() { Powered by Cortensor Network

- Decentralized AI consensus • {process.env.MODEL_NAME || 'Eureka'} Model + Decentralized AI consensus • {process.env.MODEL_NAME || 'GPT OSS 20B'} Model

diff --git a/apps/Cortensor-AIOracle/src/components/model-selector.tsx b/apps/Cortensor-AIOracle/src/components/model-selector.tsx index 9ab3ef4..ae80722 100644 --- a/apps/Cortensor-AIOracle/src/components/model-selector.tsx +++ b/apps/Cortensor-AIOracle/src/components/model-selector.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Check, ChevronDown, Bot, Zap, Brain } from 'lucide-react' +import { Check, ChevronDown, Bot, Brain } from 'lucide-react' import { ModelConfig } from '../lib/models' interface ModelSelectorProps { @@ -13,8 +13,8 @@ const getModelIcon = (modelId: string) => { switch (modelId) { case 'llava-1.5': return - case 'deepseek-r1': - return + case 'gpt-oss-20b': + return default: return } @@ -24,8 +24,8 @@ const getModelBadgeColor = (modelId: string) => { switch (modelId) { case 'llava-1.5': return 'bg-blue-50 border-blue-200 text-blue-700' - case 'deepseek-r1': - return 'bg-purple-50 border-purple-200 text-purple-700' + case 'gpt-oss-20b': + return 'bg-emerald-50 border-emerald-200 text-emerald-700' default: return 'bg-gray-50 border-gray-200 text-gray-700' } diff --git a/apps/Cortensor-AIOracle/src/components/network-status.tsx b/apps/Cortensor-AIOracle/src/components/network-status.tsx index 4dbfee5..b5d25ab 100644 --- a/apps/Cortensor-AIOracle/src/components/network-status.tsx +++ b/apps/Cortensor-AIOracle/src/components/network-status.tsx @@ -97,7 +97,7 @@ export function NetworkStatus({ className }: NetworkStatusProps) {
- Powered by Cortensor • Deepseek AI + Powered by Cortensor • GPT OSS 20B
diff --git a/apps/Cortensor-AIOracle/src/components/query-history.tsx b/apps/Cortensor-AIOracle/src/components/query-history.tsx index e147cc8..8f2250e 100644 --- a/apps/Cortensor-AIOracle/src/components/query-history.tsx +++ b/apps/Cortensor-AIOracle/src/components/query-history.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from 'react' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' import { Clock, CheckCircle, XCircle, AlertTriangle, ExternalLink, ChevronDown, ChevronUp, Globe, Newspaper, Search } from 'lucide-react' import { sanitizeModelAnswer, truncateAddress } from '@/lib/utils' @@ -47,7 +48,38 @@ export function QueryHistory({ queries }: QueryHistoryProps) { setMounted(true) }, []) - if (!mounted) return null + if (!mounted) { + return ( + + + + + + +
+
+ + + +
+ + +
+
+
+ + + +
+ + +
+
+
+
+
+ ) + } // Extract inline sources ("Sources:") from answer text so we can render them separately const extractSourcesFromAnswer = (text: string) => { diff --git a/apps/Cortensor-AIOracle/src/components/ui/skeleton.tsx b/apps/Cortensor-AIOracle/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..0a350b4 --- /dev/null +++ b/apps/Cortensor-AIOracle/src/components/ui/skeleton.tsx @@ -0,0 +1,11 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export function Skeleton({ className, ...props }: React.HTMLAttributes) { + return ( +
+ ) +} diff --git a/apps/Cortensor-AIOracle/src/lib/config.ts b/apps/Cortensor-AIOracle/src/lib/config.ts index 477476a..c0504cf 100644 --- a/apps/Cortensor-AIOracle/src/lib/config.ts +++ b/apps/Cortensor-AIOracle/src/lib/config.ts @@ -1,11 +1,17 @@ +const sessionIdEnv = process.env.NEXT_PUBLIC_OSS20B_SESSION_ID || process.env.NEXT_PUBLIC_DEEPSEEK_SESSION_ID || '5' +const parsedSessionId = Number.parseInt(sessionIdEnv, 10) +const parsedTimeout = Number.parseInt(process.env.LLM_TIMEOUT || '300', 10) +const parsedMaxTokens = Number.parseInt(process.env.LLM_MAX_TOKENS || '4096', 10) +const parsedMaxInput = Number.parseInt(process.env.NEXT_PUBLIC_MAX_INPUT_LENGTH || '2000', 10) + export const CORTENSOR_CONFIG = { ROUTER_URL: process.env.CORTENSOR_ROUTER_URL!, API_KEY: process.env.CORTENSOR_API_KEY!, COMPLETIONS_URL: process.env.NEXT_PUBLIC_CORTENSOR_COMPLETIONS_URL!, - SESSION_ID: parseInt(process.env.NEXT_PUBLIC_DEEPSEEK_SESSION_ID!), - TIMEOUT: parseInt(process.env.LLM_TIMEOUT!), - MAX_TOKENS: parseInt(process.env.LLM_MAX_TOKENS!), - MAX_INPUT_LENGTH: parseInt(process.env.NEXT_PUBLIC_MAX_INPUT_LENGTH!) + SESSION_ID: Number.isFinite(parsedSessionId) ? parsedSessionId : 5, + TIMEOUT: Number.isFinite(parsedTimeout) ? parsedTimeout : 300, + MAX_TOKENS: Number.isFinite(parsedMaxTokens) ? parsedMaxTokens : 4096, + MAX_INPUT_LENGTH: Number.isFinite(parsedMaxInput) ? parsedMaxInput : 2000 } export const ORACLE_CONFIG = { diff --git a/apps/Cortensor-AIOracle/src/lib/cortensor.ts b/apps/Cortensor-AIOracle/src/lib/cortensor.ts index 1300b02..517d1d3 100644 --- a/apps/Cortensor-AIOracle/src/lib/cortensor.ts +++ b/apps/Cortensor-AIOracle/src/lib/cortensor.ts @@ -1,4 +1,5 @@ import { OracleQuery, MinerResponse, CortensorApiResponse } from '@/types/oracle' +import { CORTENSOR_CONFIG } from '@/lib/config' class CortensorService { private apiKey: string @@ -13,11 +14,9 @@ class CortensorService { private wsListeners: Array<(data: any) => void> = [] constructor() { - this.apiKey = process.env.CORTENSOR_API_KEY! - this.baseUrl = process.env.CORTENSOR_ROUTER_URL! - const sidRaw = process.env.NEXT_PUBLIC_DEEPSEEK_SESSION_ID - const sid = sidRaw ? parseInt(sidRaw, 10) : 0 - this.sessionId = Number.isFinite(sid) ? sid : 0 + this.apiKey = CORTENSOR_CONFIG.API_KEY + this.baseUrl = CORTENSOR_CONFIG.ROUTER_URL + this.sessionId = CORTENSOR_CONFIG.SESSION_ID this.wsUrl = process.env.NEXT_PUBLIC_CORTENSOR_WS_URL || 'ws://173.214.163.250:9007' // Default to WebSocket in browser; REST remains available as fallback diff --git a/apps/Cortensor-AIOracle/src/lib/db.ts b/apps/Cortensor-AIOracle/src/lib/db.ts new file mode 100644 index 0000000..6f74366 --- /dev/null +++ b/apps/Cortensor-AIOracle/src/lib/db.ts @@ -0,0 +1,60 @@ +import fs from 'fs' +import path from 'path' +import Database from 'better-sqlite3' + +let db: Database.Database | null = null + +function ensureDirForFile(filePath: string) { + const dir = path.dirname(filePath) + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) +} + +function getDbPath() { + return ( + process.env.ORACLE_FACTS_DB_PATH || + path.join(process.cwd(), 'data', 'oracle-facts.sqlite') + ) +} + +function initSchema(database: Database.Database) { + database.pragma('journal_mode = WAL') + database.pragma('synchronous = NORMAL') + + database.exec(` + CREATE TABLE IF NOT EXISTS _migrations ( + key TEXT PRIMARY KEY, + value TEXT, + applied_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS oracle_facts ( + id TEXT PRIMARY KEY, + query TEXT NOT NULL, + answer TEXT NOT NULL, + verdict TEXT NOT NULL, + confidence REAL, + sources_json TEXT, + model_name TEXT, + query_id TEXT, + created_at TEXT NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_oracle_facts_query_id + ON oracle_facts(query_id) + WHERE query_id IS NOT NULL; + + CREATE INDEX IF NOT EXISTS idx_oracle_facts_created_at + ON oracle_facts(created_at DESC); + `) +} + +export function getSqliteDb() { + if (db) return db + + const dbPath = getDbPath() + ensureDirForFile(dbPath) + + db = new Database(dbPath) + initSchema(db) + return db +} diff --git a/apps/Cortensor-AIOracle/src/lib/models.ts b/apps/Cortensor-AIOracle/src/lib/models.ts index 2209a53..a94a6f9 100644 --- a/apps/Cortensor-AIOracle/src/lib/models.ts +++ b/apps/Cortensor-AIOracle/src/lib/models.ts @@ -19,21 +19,21 @@ export interface ModelConfig { export const AVAILABLE_MODELS: ModelConfig[] = [ { - id: 'deepseek-r1', - name: 'Deepseek R1', - displayName: 'Deepseek R1', - sessionId: process.env.NEXT_PUBLIC_DEEPSEEK_SESSION_ID || '0', + id: 'gpt-oss-20b', + name: 'GPT OSS 20B', + displayName: 'GPT OSS 20B', + sessionId: process.env.NEXT_PUBLIC_OSS20B_SESSION_ID || process.env.NEXT_PUBLIC_DEEPSEEK_SESSION_ID || '5', promptType: 1, // RAW mode - nodeType: 2, // Node type for Deepseek - maxTokens: 5000, - temperature: 0.7, - topP: 0.95, + nodeType: 2, + maxTokens: 6000, + temperature: 0.55, + topP: 0.9, topK: 40, presencePenalty: 0, frequencyPenalty: 0, - timeout: 600, // 10 minutes for more miner participation - description: 'Advanced reasoning model with step-by-step thinking', - capabilities: ['Deep Reasoning', 'Chain of Thought', 'Technical Analysis'] + timeout: 480, // prioritize faster turn-around for 20B + description: 'Open-weight GPT OSS 20B tuned for factual, source-grounded answers', + capabilities: ['Evidence-first', 'Concise Synthesis', 'Low Hallucination'] } // Llava 1.5 disabled for now // { @@ -114,39 +114,32 @@ INSTRUCTIONS FOR LLAVA 1.5 (TEXT-ONLY MODE): RESPONSE FORMAT: Provide a clear, direct answer to the query with factual backing.` - case 'deepseek-r1': + case 'gpt-oss-20b': return `${baseContext} -INSTRUCTIONS FOR DEEPSEEK R1 (RAW, HIGH-ACCURACY): -- Think step-by-step internally, but NEVER reveal chain-of-thought. Output only final conclusions. -- Use the REAL-TIME DATA SOURCES above when present; they override stale training data. -- Be specific with dates, numbers, symbols (USD $), and named entities. -- Prefer credible domains for claims (gov/edu, NASA, Reuters/AP, major newspapers, established fact-checkers). - -STRICT OUTPUT RULES: -1) Start with a direct answer in one short sentence (no preamble). If the query is a claim/hoax check, begin with “Yes,” or “No,” clearly. -2) Then provide 2–4 bullet points of key evidence with concrete facts (dates, figures, quotes), prioritized from the provided real-time sources. -3) Then list 1–3 Sources as title + URL. Use reputable links only; avoid blogs or forums unless the claim pertains specifically to them. -4) Do NOT include chain-of-thought, internal notes, or XML-like tags (no , no analysis sections). -5) Keep total output under ~180 words unless explicitly asked for more detail. - -DOMAIN-SPECIFIC GUIDANCE: -- Fact-check/Hoax: If no reputable source confirms the claim, answer “No,” and cite credible debunks (e.g., NASA/.gov, Reuters/AP, Snopes/PolitiFact/FactCheck.org/FullFact). Avoid hedging if evidence is clear. -- Numeric range (e.g., prices): Provide a range “between $X and $Y” in USD, both ends numeric with thousands separators (e.g., $120,000 and $130,000). Don’t output a single point unless asked. -- Sports/F1: Prefer official sources and the latest verified results. Be precise about event name and date. -- Markets: Prefer current price/market data when provided. Avoid predictions without a justified range and evidence. - -RESPONSE FORMAT TEMPLATE: -Answer: +INSTRUCTIONS FOR GPT OSS 20B (FACT-OPTIMIZED): +- Reason silently; never expose chain-of-thought or XML tags. +- Lead with a single-sentence answer. For claim checks, start with “Yes,” or “No,” decisively. +- Treat REAL-TIME DATA SOURCES as the primary truth; only use training data to fill small gaps. +- Use explicit numbers, dates, and currency markers (USD $). Avoid vague terms like “recently” or “about”. +- Keep total output under ~170 words unless the user asks for more. +- If uncertain, say what is missing and what would verify it. + +RESPONSE FORMAT (STRICT): +Answer: Key Evidence: -- -- -- +- +- +- Sources: -- — <url> -- <title 2> — <url> - -Do not include any other sections.` +- <title> — <url> +- <title> — <url> + +DOMAIN NOTES: +- Fact/Hoax: If no reputable confirmation, answer “No,” and cite high-credibility debunks (gov/edu, Reuters/AP, Snopes/PolitiFact/FactCheck/FullFact). +- Numeric ranges: give bounded ranges (e.g., between $120,000 and $130,000) instead of point estimates unless the user asks for a single value. +- Sports/F1: name the event and date; prefer official series sites or federation releases. +- Markets: prefer current price feeds; do not predict without evidence.` default: return `${baseContext} diff --git a/apps/Cortensor-AIOracle/src/lib/oracle-facts.ts b/apps/Cortensor-AIOracle/src/lib/oracle-facts.ts index 266b43c..d212d6c 100644 --- a/apps/Cortensor-AIOracle/src/lib/oracle-facts.ts +++ b/apps/Cortensor-AIOracle/src/lib/oracle-facts.ts @@ -1,5 +1,6 @@ import fs from 'fs/promises' import path from 'path' +import { getSqliteDb } from '@/lib/db' export type OracleFact = { id: string @@ -11,6 +12,7 @@ export type OracleFact = { title: string url: string reliability?: string + domain?: string snippet?: string publishedAt?: string publisher?: string @@ -20,45 +22,147 @@ export type OracleFact = { createdAt: string } -const FACTS_PATH = path.join(process.cwd(), 'data', 'oracle-facts.json') +const FACTS_JSON_PATH = path.join(process.cwd(), 'data', 'oracle-facts.json') +const MIGRATION_KEY = 'oracle-facts-json-v1' -async function ensureStore() { - const dir = path.dirname(FACTS_PATH) - await fs.mkdir(dir, { recursive: true }) +let migrateOnce: Promise<void> | null = null + +function safeJsonParse<T>(raw: string): T | null { try { - await fs.access(FACTS_PATH) + return JSON.parse(raw) as T } catch { - await fs.writeFile(FACTS_PATH, '[]', 'utf8') + return null } } -export async function getOracleFacts(limit = 20): Promise<OracleFact[]> { - await ensureStore() - const raw = await fs.readFile(FACTS_PATH, 'utf8') - let parsed: OracleFact[] = [] - try { - parsed = JSON.parse(raw) - } catch { - parsed = [] +async function ensureMigrated() { + if (migrateOnce) return migrateOnce + migrateOnce = (async () => { + const db = getSqliteDb() + + const already = db.prepare('SELECT 1 FROM _migrations WHERE key = ?').get(MIGRATION_KEY) + if (already) return + + let jsonRaw: string | null = null + try { + jsonRaw = await fs.readFile(FACTS_JSON_PATH, 'utf8') + } catch { + // JSON file doesn't exist; mark migration as applied so we don't re-check every request + db.prepare('INSERT OR REPLACE INTO _migrations(key, value, applied_at) VALUES(?, ?, ?)') + .run(MIGRATION_KEY, JSON.stringify({ imported: 0, reason: 'no-json-file' }), new Date().toISOString()) + return + } + + const parsed = safeJsonParse<OracleFact[]>(jsonRaw) + const facts = Array.isArray(parsed) ? parsed : [] + if (facts.length === 0) { + db.prepare('INSERT OR REPLACE INTO _migrations(key, value, applied_at) VALUES(?, ?, ?)') + .run(MIGRATION_KEY, JSON.stringify({ imported: 0, reason: 'empty-or-invalid-json' }), new Date().toISOString()) + return + } + + const insert = db.prepare(` + INSERT OR IGNORE INTO oracle_facts( + id, query, answer, verdict, confidence, sources_json, model_name, query_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + const tx = db.transaction((rows: OracleFact[]) => { + let imported = 0 + for (const f of rows) { + if (!f || typeof f !== 'object') continue + if (!f.id || !f.query || !f.answer || !f.verdict || !f.createdAt) continue + const verdict = f.verdict === 'Yes' ? 'Yes' : f.verdict === 'No' ? 'No' : null + if (!verdict) continue + + const sources = Array.isArray(f.sources) ? f.sources.slice(0, 5) : undefined + const sourcesJson = sources ? JSON.stringify(sources) : null + const confidence = typeof f.confidence === 'number' ? f.confidence : null + const modelName = typeof f.modelName === 'string' ? f.modelName : null + const queryId = typeof f.queryId === 'string' ? f.queryId : null + const createdAt = typeof f.createdAt === 'string' ? f.createdAt : new Date().toISOString() + + const info = insert.run( + String(f.id), + String(f.query), + String(f.answer), + verdict, + confidence, + sourcesJson, + modelName, + queryId, + createdAt + ) + if (info.changes > 0) imported += 1 + } + + // Keep a hard cap similar to the old JSON store + db.exec(` + DELETE FROM oracle_facts + WHERE id NOT IN ( + SELECT id FROM oracle_facts + ORDER BY created_at DESC + LIMIT 200 + ); + `) + + return imported + }) + + const imported = tx(facts) + db.prepare('INSERT OR REPLACE INTO _migrations(key, value, applied_at) VALUES(?, ?, ?)') + .run(MIGRATION_KEY, JSON.stringify({ imported, from: 'data/oracle-facts.json' }), new Date().toISOString()) + })() + return migrateOnce +} + +function rowToFact(row: any): OracleFact { + const sources = row?.sources_json ? safeJsonParse<OracleFact['sources']>(row.sources_json) : null + return { + id: String(row.id), + query: String(row.query), + answer: String(row.answer), + verdict: row.verdict === 'Yes' ? 'Yes' : 'No', + confidence: typeof row.confidence === 'number' ? row.confidence : undefined, + sources: Array.isArray(sources) ? sources : undefined, + modelName: row.model_name != null ? String(row.model_name) : undefined, + queryId: row.query_id != null ? String(row.query_id) : undefined, + createdAt: String(row.created_at), } - const sorted = parsed.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - return sorted.slice(0, Math.max(1, limit)) +} + +export async function getOracleFacts(limit = 20): Promise<OracleFact[]> { + await ensureMigrated() + const db = getSqliteDb() + const safeLimit = Math.max(1, Math.min(100, Math.floor(limit))) + + const rows = db + .prepare(` + SELECT id, query, answer, verdict, confidence, sources_json, model_name, query_id, created_at + FROM oracle_facts + ORDER BY created_at DESC + LIMIT ? + `) + .all(safeLimit) + + return rows.map(rowToFact) } export async function addOracleFact(fact: Omit<OracleFact, 'id' | 'createdAt'> & { id?: string; createdAt?: string }) { - await ensureStore() - const raw = await fs.readFile(FACTS_PATH, 'utf8') - let parsed: OracleFact[] = [] - try { - parsed = JSON.parse(raw) - } catch { - parsed = [] - } + await ensureMigrated() + const db = getSqliteDb() // Skip duplicates based on queryId when available if (fact.queryId) { - const exists = parsed.some(f => f.queryId === fact.queryId) - if (exists) return parsed.find(f => f.queryId === fact.queryId) as OracleFact + const existing = db + .prepare(` + SELECT id, query, answer, verdict, confidence, sources_json, model_name, query_id, created_at + FROM oracle_facts + WHERE query_id = ? + LIMIT 1 + `) + .get(fact.queryId) + if (existing) return rowToFact(existing) } const record: OracleFact = { @@ -73,8 +177,33 @@ export async function addOracleFact(fact: Omit<OracleFact, 'id' | 'createdAt'> & createdAt: fact.createdAt || new Date().toISOString() } - const next = [record, ...parsed] - const capped = next.slice(0, 200) - await fs.writeFile(FACTS_PATH, JSON.stringify(capped, null, 2), 'utf8') + const insert = db.prepare(` + INSERT OR IGNORE INTO oracle_facts( + id, query, answer, verdict, confidence, sources_json, model_name, query_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + insert.run( + record.id, + record.query, + record.answer, + record.verdict, + typeof record.confidence === 'number' ? record.confidence : null, + record.sources ? JSON.stringify(record.sources) : null, + record.modelName || null, + record.queryId || null, + record.createdAt + ) + + // Keep a hard cap similar to the old JSON store + db.exec(` + DELETE FROM oracle_facts + WHERE id NOT IN ( + SELECT id FROM oracle_facts + ORDER BY created_at DESC + LIMIT 200 + ); + `) + return record } diff --git a/apps/Cortensor-AnalystAI/.gitignore b/apps/Cortensor-AnalystAI/.gitignore new file mode 100644 index 0000000..bba4de7 --- /dev/null +++ b/apps/Cortensor-AnalystAI/.gitignore @@ -0,0 +1,198 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +myenv/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Project specific files +data/ +logs/ +cache/ +output/ +charts/ +temp/ +tmp/ + +# Database files +*.db +*.sqlite +*.sqlite3 + +# API keys and sensitive files +.env.local +.env.production +.env.staging +config.local.py +secrets.json + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Backup files +*.bak +*.backup +*.old + +# Chart images and temporary files +*.png +*.jpg +*.jpeg +*.gif +*.svg +price_chart_*.png +chart_*.png + +# Log files +*.log +llama.log +analyst.log +bot.log + +# Telegram bot specific +state_data/ +restart_logs/ + +# APScheduler job store +schedules_jobs.db + +# Cache files +cache.db +*.cache diff --git a/apps/Cortensor-AnalystAI/README.md b/apps/Cortensor-AnalystAI/README.md index 3e6fdce..54427cd 100644 --- a/apps/Cortensor-AnalystAI/README.md +++ b/apps/Cortensor-AnalystAI/README.md @@ -8,8 +8,9 @@ <p> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License"></a> -<a href="./STATUS.md"\><img src="https://img.shields.io/badge/status-active-success.svg" alt="Status"></a> +<a href="./STATUS.md"><img src="https://img.shields.io/badge/status-production--ready-success.svg" alt="Status"></a> <a href="#"><img src="https://img.shields.io/badge/Next.js-15.2-blue.svg" alt="Next.js Version"></a> +<a href="https://t.me/cortensor"><img src="https://img.shields.io/badge/Telegram-%232CA5E0.svg?logo=telegram&logoColor=white" alt="Telegram"></a> </p> <p align="center"> @@ -169,6 +170,18 @@ The bot uses TinyDB with the following databases: - `data/tasks.db` - Analysis task queue - `data/schedules.db` - Scheduled analysis jobs +## 🚦 Project Status + +- ✅ **Active Development** - Actively maintained and updated +- ✅ **Production Ready** - Stable for production use +- ✅ **Full Feature Set** - Complete DCA and portfolio management +- ✅ **Multi-Currency Support** - USD, IDR, EUR, JPY +- ✅ **Timezone Support** - Per-user timezone configuration + +## 🤝 Contributing + +We welcome contributions! Please see our [development workflow](https://github.com/cortensor/cortensor-community-projects#-development-workflow) for guidelines. + ## 👤 Maintainer - **@beranalpa** (Discord: @beranalpagion) diff --git a/apps/Cortensor-AnalystAI/requirements.txt b/apps/Cortensor-AnalystAI/requirements.txt index ccb886f..7c5e91e 100644 --- a/apps/Cortensor-AnalystAI/requirements.txt +++ b/apps/Cortensor-AnalystAI/requirements.txt @@ -10,3 +10,4 @@ thefuzz[speedup] yfinance SQLAlchemy<2.0 pytz +matplotlib diff --git a/apps/Cortensor-AnalystAI/src/bot/formatter.py b/apps/Cortensor-AnalystAI/src/bot/formatter.py index f7f35fd..ef91453 100644 --- a/apps/Cortensor-AnalystAI/src/bot/formatter.py +++ b/apps/Cortensor-AnalystAI/src/bot/formatter.py @@ -21,6 +21,11 @@ def escape_html(text: str) -> str: # First, remove any MarkdownV2 specific escapes (\) that might be left # This is crucial because HTML doesn't use them and they appear as literal \. cleaned_text = text.replace('\\', '') + + # Remove special inference markers sometimes returned by models (e.g., DeepSeek) + markers_to_strip = ["<|end_of_sentence|>", "<|end▁of▁sentence|>"] + for marker in markers_to_strip: + cleaned_text = cleaned_text.replace(marker, '') # Remove </s> or <s> tags if present in AI output cleaned_text = re.sub(r'</?s>', '', cleaned_text).strip() @@ -247,9 +252,15 @@ def format_final_message( now = escape_html(datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')) escaped_takeaway = escape_html(takeaway) # Escape the fallback or parsed takeaway - company_name = market_data.get('company_name', '').strip() - ticker = market_data.get('symbol', topic.upper()).strip().upper() - asset_type = market_data.get('type', '').strip().lower() + raw_company = market_data.get('company_name') + company_name = raw_company.strip() if isinstance(raw_company, str) else '' + + raw_symbol = market_data.get('symbol') + symbol_source = raw_symbol if isinstance(raw_symbol, str) and raw_symbol.strip() else topic + ticker = symbol_source.strip().upper() + + raw_asset_type = market_data.get('type') + asset_type = raw_asset_type.strip().lower() if isinstance(raw_asset_type, str) else '' # --- HEADER LABEL LOGIC --- if asset_type == 'crypto': header_label = 'Crypto' diff --git a/apps/Cortensor-AnalystAI/src/config.py b/apps/Cortensor-AnalystAI/src/config.py index f8ae0ca..720fcba 100644 --- a/apps/Cortensor-AnalystAI/src/config.py +++ b/apps/Cortensor-AnalystAI/src/config.py @@ -8,15 +8,19 @@ # --- Telegram Bot Configuration --- TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") -# DEBUG PRINTS - JANGAN HAPUS INI UNTUK SEMENTARA -print(f"DEBUG: TELEGRAM_BOT_TOKEN loaded as: {TELEGRAM_BOT_TOKEN[:5]}... (first 5 chars)") -print(f"DEBUG: TELEGRAM_BOT_TOKEN type: {type(TELEGRAM_BOT_TOKEN)}") # --- Cortensor Node Configuration --- CORTENSOR_API_URL = os.getenv("CORTENSOR_API_URL") CORTENSOR_API_KEY = os.getenv("CORTENSOR_API_KEY") CORTENSOR_SESSION_ID = os.getenv("CORTENSOR_SESSION_ID") +# --- Cortensor AI Model Parameters --- +CORTENSOR_TEMPERATURE = float(os.getenv("CORTENSOR_TEMPERATURE", "0.3")) +CORTENSOR_PROMPT_TYPE = int(os.getenv("CORTENSOR_PROMPT_TYPE", "1")) +CORTENSOR_MAX_TOKENS = int(os.getenv("CORTENSOR_MAX_TOKENS", "5024")) +CORTENSOR_TIMEOUT = int(os.getenv("CORTENSOR_TIMEOUT", "300")) +CORTENSOR_REQUEST_TIMEOUT = int(os.getenv("CORTENSOR_REQUEST_TIMEOUT", "320")) + # --- External Service API Keys --- NEWS_API_KEY = os.getenv("NEWS_API_KEY") FINNHUB_API_KEY = os.getenv("FINNHUB_API_KEY") @@ -24,12 +28,36 @@ COINGECKO_API_KEY = os.getenv("COINGECKO_API_KEY") FMP_API_KEY = os.getenv("FMP_API_KEY") +# --- External API URLs --- +COINGECKO_API_URL = os.getenv("COINGECKO_API_URL", "https://api.coingecko.com/api/v3") +FMP_API_URL = os.getenv("FMP_API_URL", "https://financialmodelingprep.com/stable") +NEWSAPI_URL = os.getenv("NEWSAPI_URL", "https://newsapi.org/v2") +EXCHANGE_RATE_API_URL = os.getenv("EXCHANGE_RATE_API_URL", "https://api.exchangerate-api.com/v4/latest") + # --- Application Settings --- LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG") DATABASE_PATH = os.getenv("DATABASE_PATH", "data/tasks.db") SCHEDULER_DB_PATH = os.getenv("SCHEDULER_DB_PATH", "data/schedules_jobs.db") DEFAULT_TIMEZONE = os.getenv("DEFAULT_TIMEZONE", "Asia/Jakarta") CACHE_PATH = os.getenv("CACHE_PATH", "data/cache.db") -CACHE_EXPIRATION_MINUTES = int(os.getenv("CACHE_EXPIRATION_MINUTES", 60)) -RETRY_ATTEMPTS = int(os.getenv("RETRY_ATTEMPTS", 3)) -RETRY_DELAY_SECONDS = int(os.getenv("RETRY_DELAY_SECONDS", 10)) \ No newline at end of file +CACHE_EXPIRATION_MINUTES = int(os.getenv("CACHE_EXPIRATION_MINUTES", "60")) +RETRY_ATTEMPTS = int(os.getenv("RETRY_ATTEMPTS", "3")) +RETRY_DELAY_SECONDS = int(os.getenv("RETRY_DELAY_SECONDS", "10")) + +# --- Currency & Exchange Rate Settings --- +EXCHANGE_RATE_CACHE_DURATION = int(os.getenv("EXCHANGE_RATE_CACHE_DURATION", "3600")) +DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "USD") + +# --- News Fetch Settings --- +NEWS_LOOKBACK_DAYS = int(os.getenv("NEWS_LOOKBACK_DAYS", "2")) +NEWS_EXTENDED_LOOKBACK_DAYS = int(os.getenv("NEWS_EXTENDED_LOOKBACK_DAYS", "7")) +NEWS_PAGE_SIZE = int(os.getenv("NEWS_PAGE_SIZE", "5")) +NEWS_FUZZY_MATCH_THRESHOLD = int(os.getenv("NEWS_FUZZY_MATCH_THRESHOLD", "75")) + +# --- API Request Timeouts (seconds) --- +DEFAULT_API_TIMEOUT = int(os.getenv("DEFAULT_API_TIMEOUT", "10")) +FMP_PROFILE_TIMEOUT = int(os.getenv("FMP_PROFILE_TIMEOUT", "5")) + +# --- User Default Settings --- +DEFAULT_ALERTS_THRESHOLD = float(os.getenv("DEFAULT_ALERTS_THRESHOLD", "5.0")) +DEFAULT_DATA_RETENTION_DAYS = int(os.getenv("DEFAULT_DATA_RETENTION_DAYS", "30")) \ No newline at end of file diff --git a/apps/Cortensor-AnalystAI/src/core/cortensor_client.py b/apps/Cortensor-AnalystAI/src/core/cortensor_client.py index 1844080..8ea1436 100644 --- a/apps/Cortensor-AnalystAI/src/core/cortensor_client.py +++ b/apps/Cortensor-AnalystAI/src/core/cortensor_client.py @@ -1,6 +1,15 @@ import logging import requests -from src.config import CORTENSOR_API_URL, CORTENSOR_API_KEY, CORTENSOR_SESSION_ID +from src.config import ( + CORTENSOR_API_URL, + CORTENSOR_API_KEY, + CORTENSOR_SESSION_ID, + CORTENSOR_TEMPERATURE, + CORTENSOR_PROMPT_TYPE, + CORTENSOR_MAX_TOKENS, + CORTENSOR_TIMEOUT, + CORTENSOR_REQUEST_TIMEOUT +) logger = logging.getLogger(__name__) @@ -25,16 +34,16 @@ def get_ai_analysis(request_id: str, prompt: str) -> str | None: payload = { "session_id": int(CORTENSOR_SESSION_ID), "prompt": prompt, - "prompt_type": 1, - "temperature": 0.3, + "prompt_type": CORTENSOR_PROMPT_TYPE, + "temperature": CORTENSOR_TEMPERATURE, "stream": False, - "timeout": 300, - "max_tokens": 5024 + "timeout": CORTENSOR_TIMEOUT, + "max_tokens": CORTENSOR_MAX_TOKENS } try: - logger.info(f"Sending request for ID {request_id} to Cortensor API with temperature=0.3.") - response = requests.post(CORTENSOR_API_URL, headers=headers, json=payload, timeout=320) + logger.info(f"Sending request for ID {request_id} to Cortensor API with temperature={CORTENSOR_TEMPERATURE}.") + response = requests.post(CORTENSOR_API_URL, headers=headers, json=payload, timeout=CORTENSOR_REQUEST_TIMEOUT) response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) response_data = response.json() diff --git a/apps/Cortensor-AnalystAI/src/core/dca_worker.py b/apps/Cortensor-AnalystAI/src/core/dca_worker.py index 67959d6..166f4c4 100644 --- a/apps/Cortensor-AnalystAI/src/core/dca_worker.py +++ b/apps/Cortensor-AnalystAI/src/core/dca_worker.py @@ -5,6 +5,7 @@ from apscheduler.triggers.cron import CronTrigger from pytz import timezone +from src.config import DEFAULT_TIMEZONE from src.utils.database import ( get_user_dca_schedules, update_dca_execution, @@ -13,6 +14,12 @@ get_active_dca_schedules ) from src.services.market_data_api import get_market_data +from src.services.currency_api import ( + convert_currency, + get_conversion_rate, + format_currency, + get_exchange_rates +) logger = logging.getLogger(__name__) @@ -21,7 +28,7 @@ class DCAWorker: def __init__(self, bot=None): self.bot = bot - self.scheduler = BackgroundScheduler(timezone=timezone('Asia/Jakarta')) + self.scheduler = BackgroundScheduler(timezone=timezone(DEFAULT_TIMEZONE)) def start(self): """Start the DCA scheduler.""" @@ -65,7 +72,7 @@ def _check_and_execute_dca_schedules(self): # Process schedules by user timezone for user_id, user_data in user_schedules.items(): user_settings = user_data['settings'] - user_timezone = user_settings.get('timezone', 'Asia/Jakarta') + user_timezone = user_settings.get('timezone', DEFAULT_TIMEZONE) # Get current time in user's timezone from pytz import timezone @@ -131,20 +138,35 @@ def _execute_dca_purchase(self, schedule): logger.warning(f"No market data for {schedule['symbol']} - skipping DCA execution") return - current_price_usd = market_data.get('current_price', 0) - if current_price_usd <= 0: - logger.warning(f"Invalid price for {schedule['symbol']} - skipping DCA execution") + raw_price = market_data.get('current_price') + try: + current_price_usd = float(raw_price) + except (TypeError, ValueError): + current_price_usd = None + + if current_price_usd is None or current_price_usd <= 0: + logger.warning( + f"Invalid price '{raw_price}' for {schedule['symbol']} - skipping DCA execution" + ) return # Convert amount to USD if needed (main currency system) - conversion_rates = {'USD': 1.0, 'IDR': 15000.0, 'EUR': 0.85, 'JPY': 110.0} schedule_currency = schedule['currency'] - amount_in_schedule_currency = schedule['amount'] + + try: + amount_in_schedule_currency = float(schedule['amount']) + except (TypeError, ValueError): + logger.warning( + f"Invalid amount '{schedule['amount']}' for {schedule['symbol']} - skipping DCA execution" + ) + return - # Convert to USD as base currency + # Convert to USD as base currency using real-time rates if schedule_currency != 'USD': - conversion_rate = conversion_rates.get(schedule_currency, 1.0) - amount_usd = amount_in_schedule_currency / conversion_rate + amount_usd = convert_currency(amount_in_schedule_currency, schedule_currency, 'USD') + if amount_usd is None: + logger.warning(f"Currency conversion failed for {schedule_currency} - skipping DCA execution") + return else: amount_usd = amount_in_schedule_currency @@ -194,28 +216,29 @@ async def _send_dca_notification(self, schedule, quantity, price, amount): return # User has notifications disabled # Get user's timezone for proper time display - user_timezone = user_settings.get('timezone', 'Asia/Jakarta') + user_timezone = user_settings.get('timezone', DEFAULT_TIMEZONE) from pytz import timezone user_tz = timezone(user_timezone) current_time_user = datetime.now(user_tz) - currency_symbols = { - 'USD': '$', 'EUR': '€', 'JPY': '¥', 'IDR': 'Rp', - 'GBP': '£', 'SGD': 'S$', 'AUD': 'A$', 'CAD': 'C$' - } - currency_symbol = currency_symbols.get(schedule['currency'], schedule['currency']) - - # Convert price to schedule currency if needed - conversion_rates = {'USD': 1.0, 'IDR': 15000.0, 'EUR': 0.85, 'JPY': 110.0} - conversion_rate = conversion_rates.get(schedule['currency'], 1.0) + # Convert price to schedule currency using real-time rates + schedule_currency = schedule['currency'] + conversion_rate = get_conversion_rate('USD', schedule_currency) + if conversion_rate is None: + conversion_rate = 1.0 # Fallback to USD if conversion fails price_converted = price * conversion_rate + # Format the amount with proper currency symbol + amount_formatted = format_currency(amount, schedule_currency) + + price_formatted = format_currency(price_converted, schedule_currency) + message = ( f"🎯 <b>DCA Executed!</b>\n\n" f"💰 <b>Asset:</b> {schedule['symbol'].upper()}\n" - f"💵 <b>Amount:</b> {currency_symbol}{amount:,.2f} {schedule['currency']}\n" + f"💵 <b>Amount:</b> {amount_formatted}\n" f"📦 <b>Quantity:</b> {quantity:.6f}\n" - f"💎 <b>Price:</b> {currency_symbol}{price_converted:,.2f}\n" + f"💎 <b>Price:</b> {price_formatted}\n" f"⏰ <b>Time:</b> {current_time_user.strftime('%Y-%m-%d %H:%M:%S')} {user_timezone}\n" f"🔄 <b>Frequency:</b> {schedule['frequency'].title()}\n\n" f"✅ Your portfolio has been updated!" diff --git a/apps/Cortensor-AnalystAI/src/core/prompt_builder.py b/apps/Cortensor-AnalystAI/src/core/prompt_builder.py index c570d4c..5bc9fa4 100644 --- a/apps/Cortensor-AnalystAI/src/core/prompt_builder.py +++ b/apps/Cortensor-AnalystAI/src/core/prompt_builder.py @@ -5,30 +5,85 @@ def build_analyst_prompt(market_data: dict, news_data: list[dict]) -> str: Builds a DeepSeek R1–compatible prompt for zero‑hallucination, strict sentiment alignment, and English‑only expert analysis. """ - asset_type = market_data.get('type', 'Asset') - asset_name = market_data.get('name', 'N/A') + def safe_text(value, default=''): + if isinstance(value, str): + return value + if value is None: + return default + return str(value) + + def safe_float(value, default=0.0): + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + cleaned = value.replace(',', '').strip() + if not cleaned: + return default + try: + return float(cleaned) + except ValueError: + return default + return default + + asset_type_raw = market_data.get('type') + asset_type = safe_text(asset_type_raw, 'Asset').strip() or 'Asset' + + asset_name_source = ( + market_data.get('name') + or market_data.get('company_name') + or market_data.get('symbol') + or 'N/A' + ) + asset_name = safe_text(asset_name_source, 'N/A').strip() or 'N/A' # Market Data block summary = [f"## MARKET DATA for {asset_name.upper()} ({asset_type.upper()}):"] - if asset_type == "Crypto": + if asset_type.lower() == "crypto": + current_price = safe_float(market_data.get('current_price')) + change_24h = safe_float(market_data.get('price_change_24h_pct')) + change_7d = safe_float(market_data.get('price_change_7d_pct')) + change_30d = safe_float(market_data.get('price_change_30d_pct')) + volume_24h = safe_float(market_data.get('trading_volume_24h')) + market_cap = safe_float(market_data.get('market_cap')) + summary += [ - f"- Current Price: ${market_data.get('current_price', 0):,.2f} USD", - f"- 24h Change: {market_data.get('price_change_24h_pct', 0):+.2f}%", - f"- 7d Change: {market_data.get('price_change_7d_pct', 0):+.2f}%", - f"- 30d Change: {market_data.get('price_change_30d_pct', 0):+.2f}%", - f"- 24h Volume: ${market_data.get('trading_volume_24h', 0):,.0f} USD", - f"- Market Cap: ${market_data.get('market_cap', 0):,.0f} USD" + f"- Current Price: ${current_price:,.2f} USD", + f"- 24h Change: {change_24h:+.2f}%", + f"- 7d Change: {change_7d:+.2f}%", + f"- 30d Change: {change_30d:+.2f}%", + f"- 24h Volume: ${volume_24h:,.0f} USD", + f"- Market Cap: ${market_cap:,.0f} USD" ] else: # Stock type + current_price = safe_float(market_data.get('current_price')) + price_change_pct = safe_float(market_data.get('price_change_pct')) + change_7d = safe_float(market_data.get('price_change_7d_pct')) + change_30d = safe_float(market_data.get('price_change_30d_pct')) + trading_volume = safe_float(market_data.get('trading_volume')) + market_cap = safe_float(market_data.get('market_cap')) + + pe_ratio_value = safe_text(market_data.get('pe_ratio')) + eps_value = safe_text(market_data.get('eps_ttm')) + + try: + pe_ratio_display = f"{float(pe_ratio_value):.2f}" + except (TypeError, ValueError): + pe_ratio_display = pe_ratio_value.strip() if pe_ratio_value and pe_ratio_value.strip() else 'N/A' + + try: + eps_display = f"{float(eps_value):.2f}" + except (TypeError, ValueError): + eps_display = eps_value.strip() if eps_value and eps_value.strip() else 'N/A' + summary += [ - f"- Current Price: ${market_data.get('current_price', 0):,.2f} USD", - f"- Today’s Change: {market_data.get('price_change_pct', 0):+.2f}%", - f"- 7d Change: {market_data.get('price_change_7d_pct', 0):+.2f}%", - f"- 30d Change: {market_data.get('price_change_30d_pct', 0):+.2f}%", - f"- Volume Today: {market_data.get('trading_volume', 0):,.0f} shares", - f"- Market Cap: ${market_data.get('market_cap', 0):,.0f} USD", - f"- P/E Ratio: {market_data.get('pe_ratio', 'N/A')}", - f"- EPS (TTM): {market_data.get('eps_ttm', 'N/A')}" + f"- Current Price: ${current_price:,.2f} USD", + f"- Today’s Change: {price_change_pct:+.2f}%", + f"- 7d Change: {change_7d:+.2f}%", + f"- 30d Change: {change_30d:+.2f}%", + f"- Volume Today: {trading_volume:,.0f} shares", + f"- Market Cap: ${market_cap:,.0f} USD", + f"- P/E Ratio: {pe_ratio_display}", + f"- EPS (TTM): {eps_display}" ] # News block diff --git a/apps/Cortensor-AnalystAI/src/core/worker.py b/apps/Cortensor-AnalystAI/src/core/worker.py index ad8982c..597927c 100644 --- a/apps/Cortensor-AnalystAI/src/core/worker.py +++ b/apps/Cortensor-AnalystAI/src/core/worker.py @@ -1,23 +1,179 @@ +# src/core/worker.py + +""" +Background Worker for Processing Analysis Tasks +Handles queued analysis requests with retry logic, caching, and notification delivery. +""" + +import logging +import re +import time +import threading +import asyncio import os +import json +from datetime import datetime + +import matplotlib +matplotlib.use('Agg') # Non-interactive backend for server environments +import matplotlib.pyplot as plt +import pytz from telegram import InputFile +from telegram.error import BadRequest + +from src.config import RETRY_ATTEMPTS, RETRY_DELAY_SECONDS, DEFAULT_TIMEZONE +from src.utils.database import ( + get_pending_task, + update_task_status, + update_task_result, + increment_task_attempts +) +from src.utils.caching import get_cached_result, set_cached_result +from src.services.market_data_api import get_market_data +from src.services.news_api import fetch_relevant_news +from src.core.prompt_builder import build_analyst_prompt +from src.core.cortensor_client import get_ai_analysis +from src.bot.formatter import format_final_message, _clean_ai_output, escape_html + +logger = logging.getLogger(__name__) + + +def is_response_valid(response_text: str) -> bool: + """A simple validation layer to check the quality of the AI response.""" + if not response_text or len(response_text) < 20: + logger.warning("Validation failed: AI response is too short or empty.") + return False + return True + + async def send_chart_if_exists(bot, user_id, price_chart_path): + """Send price chart image to user if it exists.""" if price_chart_path and os.path.exists(price_chart_path): try: with open(price_chart_path, 'rb') as f: await bot.send_photo(chat_id=user_id, photo=InputFile(f)) + logger.info(f"Chart sent to user {user_id}") except Exception as e: logger.warning(f"Failed to send chart image: {e}") + + +async def send_final_message(bot, user_id, message_text, ack_message_id=None): + """Safely sends a message to the user and deletes acknowledgment message if provided.""" + try: + # Send the final result message + await bot.send_message( + user_id, + message_text, + parse_mode='HTML', + disable_web_page_preview=True + ) + + # Delete the acknowledgment message if provided + if ack_message_id: + try: + await bot.delete_message(chat_id=user_id, message_id=ack_message_id) + logger.info(f"Deleted acknowledgment message {ack_message_id} for user {user_id}") + except Exception as e: + logger.warning(f"Could not delete acknowledgment message {ack_message_id}: {e}") + + return True + except BadRequest as e: + logger.error(f"Failed to send message: {e}") + return False + + +def generate_price_chart(topic: str, request_id: str, price_history: list) -> str | None: + """ + Generate price chart from historical data. + + Args: + topic: Asset name for chart title + request_id: Unique request ID for file naming + price_history: List of dicts with 'date' and 'price' keys + + Returns: + str: Path to generated chart image, or None if generation fails + """ + if not price_history or not isinstance(price_history, list) or len(price_history) < 2: + return None + + try: + dates = [x['date'] for x in price_history] + prices = [x['price'] for x in price_history] + + fig, ax = plt.subplots(figsize=(10, 6)) + ax.plot(dates, prices, marker='o', linewidth=2, markersize=4) + ax.set_title(f"Price Chart for {topic}", fontsize=14, fontweight='bold') + ax.set_xlabel("Date", fontsize=12) + ax.set_ylabel("Price (USD)", fontsize=12) + ax.grid(True, alpha=0.3) + + # Improve date formatting + plt.xticks(rotation=45, ha='right') + plt.tight_layout() + + # Save chart + chart_dir = os.path.join("output", "charts") + os.makedirs(chart_dir, exist_ok=True) + price_chart_path = os.path.join(chart_dir, f"{request_id}_price_chart.png") + plt.savefig(price_chart_path, dpi=100, bbox_inches='tight') + plt.close(fig) + + logger.info(f"Price chart generated: {price_chart_path}") + return price_chart_path + + except Exception as e: + logger.warning(f"Failed to generate price chart: {e}") + return None + + +def clean_opinion_text(text: str) -> str: + """Clean opinion text by removing sentiment prefixes.""" + return re.sub( + r'^(Bullish|Bearish|Neutral|Positive|Negative|\*+|:|\s)+[:\*\s-]*', + '', + text, + flags=re.IGNORECASE + ).strip() + + +def clean_key_takeaway(text: str) -> str: + """Clean key takeaway text by removing formatting prefixes.""" + return re.sub(r'^[\*:\s]+', '', text).strip() + + +def parse_competitors_from_ai_response(ai_result_text: str) -> list: + """Extract competitors list from AI response if present.""" + try: + ai_json = None + if ai_result_text.strip().startswith('{'): + ai_json = json.loads(ai_result_text) + elif '\n{"competitors":' in ai_result_text: + ai_json = json.loads(ai_result_text.split('\n', 1)[1]) + + if ai_json and 'competitors' in ai_json: + return ai_json['competitors'] + except Exception: + pass + return [] + + async def process_single_task(bot): + """Process a single task from the queue.""" task = get_pending_task() if not task: return - request_id, topic, user_id = task['request_id'], task['topic'], task['user_id'] - ack_message_id = task.get('ack_message_id') # Get acknowledgment message ID - analysis_type = task.get('analysis_type', 'manual') # Get analysis type, default to manual + request_id = task['request_id'] + topic = task['topic'] + user_id = task['user_id'] + ack_message_id = task.get('ack_message_id') + analysis_type = task.get('analysis_type', 'manual') + logger.info(f"Worker picked up task {request_id} for topic '{topic}' (type: {analysis_type}).") update_task_status(request_id, 'PROCESSING') + # Check cache first cached_result = get_cached_result(topic) if cached_result: logger.info(f"Found cached result for '{topic}'. Using cache.") @@ -27,62 +183,43 @@ async def process_single_task(bot): update_task_status(request_id, 'FAILED') return + # Process with retry logic for attempt in range(RETRY_ATTEMPTS): current_attempt = increment_task_attempts(request_id) logger.info(f"Processing attempt {current_attempt}/{RETRY_ATTEMPTS} for {request_id}.") + try: + # Fetch market data market_data = get_market_data(topic) if not market_data: - error_message = f"❌ Analysis for '{topic}' failed: Could not fetch market data. Please check the ticker symbol." + error_message = ( + f"❌ Analysis for '{topic}' failed: Could not fetch market data. " + "Please check the ticker symbol." + ) await send_final_message(bot, user_id, error_message, ack_message_id) update_task_status(request_id, 'FAILED') return asset_type = market_data.get('type', 'Unknown') asset_symbol = market_data.get('symbol', topic) + + # Fetch news news_data = fetch_relevant_news(topic, asset_type, asset_symbol) + + # Build prompt and get AI analysis prompt = build_analyst_prompt(market_data, news_data) ai_result_text = get_ai_analysis(request_id, prompt) - # --- Tambahkan pembuatan grafik harga di sini --- - price_chart_path = None - try: - price_history = market_data.get('price_history') - if price_history and isinstance(price_history, list) and len(price_history) > 1: - dates = [x['date'] for x in price_history] - prices = [x['price'] for x in price_history] - fig, ax = plt.subplots() - ax.plot(dates, prices, marker='o') - ax.set_title(f"Price Chart for {topic}") - ax.set_xlabel("Date") - ax.set_ylabel("Price (USD)") - ax.grid(True) - chart_dir = os.path.join("output", "charts") - os.makedirs(chart_dir, exist_ok=True) - price_chart_path = os.path.join(chart_dir, f"{request_id}_price_chart.png") - plt.xticks(rotation=30) - plt.tight_layout() - plt.savefig(price_chart_path) - plt.close(fig) - except Exception as e: - logger.warning(f"Failed to generate price chart: {e}") - price_chart_path = None - # --- Akhir pembuatan grafik --- + # Generate price chart + price_chart_path = generate_price_chart( + topic, + request_id, + market_data.get('price_history') + ) if ai_result_text and is_response_valid(ai_result_text): - competitors = [] - try: - import json - ai_json = None - if ai_result_text.strip().startswith('{'): - ai_json = json.loads(ai_result_text) - elif '\n{"competitors":' in ai_result_text: - ai_json = json.loads(ai_result_text.split('\n',1)[1]) - if ai_json and 'competitors' in ai_json: - competitors = ai_json['competitors'] - except Exception: - competitors = [] - + # Parse AI response + competitors = parse_competitors_from_ai_response(ai_result_text) parsed = _clean_ai_output( ai_result_text, [n.get('title', '') for n in news_data] if news_data else [] @@ -90,19 +227,13 @@ async def process_single_task(bot): opinions = parsed.get('opinions', []) key_takeaway = parsed.get('key_takeaway', '') - def clean_opinion_text(text): - return re.sub(r'^(Bullish|Bearish|Neutral|Positive|Negative|\*+|:|\s)+[:\*\s-]*', '', text, flags=re.IGNORECASE).strip() - - def clean_key_takeaway(text): - return re.sub(r'^[\*:\s]+', '', text).strip() - - # Get current time in Jakarta timezone - import pytz - jakarta_tz = pytz.timezone('Asia/Jakarta') - current_time = datetime.now(jakarta_tz) + # Get current time in configured timezone + default_tz = pytz.timezone(DEFAULT_TIMEZONE) + current_time = datetime.now(default_tz) date_str = current_time.strftime('%A, %d %B %Y') time_str = current_time.strftime('%H:%M WIB') + # Build report sections header_lines = [ '💎 Analyst Report 💎', '', @@ -113,25 +244,28 @@ def clean_key_takeaway(text): '━━━━━━━━━━━━━━━━━━', '' ] + opinion_lines = ['Expert Opinions:'] for idx, (sentiment, text) in enumerate(opinions): - emoji = {'Bullish':'🟢','Bearish':'🔴','Neutral':'🟡'}.get(sentiment,'🟡') + emoji = {'Bullish': '🟢', 'Bearish': '🔴', 'Neutral': '🟡'}.get(sentiment, '🟡') cleaned = clean_opinion_text(text) opinion_lines.append(f"• [Expert {idx+1}] {emoji} {sentiment}: {escape_html(cleaned)}") if not opinions: opinion_lines.append('• <i>No expert opinions available.</i>') opinion_lines.append('') + key_lines = [ '🔑 <b>Key Takeaway:</b>', escape_html(clean_key_takeaway(key_takeaway)) if key_takeaway else '<i>No summary was generated.</i>', '' ] + news_lines = ['News Summary:'] if news_data: - for idx, n in enumerate(news_data,1): - title = escape_html(n.get('title','')) - url = n.get('url','') - summary = escape_html(n.get('summary','')) + for idx, n in enumerate(news_data, 1): + title = escape_html(n.get('title', '')) + url = n.get('url', '') + summary = escape_html(n.get('summary', '')) if summary: news_lines.append(f"• News {idx}: {title} ({url})\n {summary}") else: @@ -139,11 +273,14 @@ def clean_key_takeaway(text): else: news_lines.append('• <i>No news found.</i>') news_lines.append('') + disclaimer = [ - 'Disclaimer: This report is for informational purposes only and does not constitute financial advice. Please do your own research before making investment decisions.' + 'Disclaimer: This report is for informational purposes only and does not ' + 'constitute financial advice. Please do your own research before making ' + 'investment decisions.' ] - lines = header_lines + opinion_lines + key_lines - lines += news_lines + disclaimer + + # Format final message final_message = format_final_message( topic, ai_result_text, @@ -151,8 +288,9 @@ def clean_key_takeaway(text): news_data, price_chart_path=price_chart_path, competitors=competitors, - analysis_type=analysis_type # Use analysis type from task + analysis_type=analysis_type ) + if await send_final_message(bot, user_id, final_message, ack_message_id): await send_chart_if_exists(bot, user_id, price_chart_path) update_task_result(request_id, 'COMPLETED', final_message) @@ -167,60 +305,16 @@ def clean_key_takeaway(text): except Exception as e: logger.error(f"Error on attempt {current_attempt} for {request_id}: {e}") + # Wait before retry (except on last attempt) if current_attempt < RETRY_ATTEMPTS: time.sleep(RETRY_DELAY_SECONDS) else: - error_message = f"❌ Analysis for '{topic}' failed after multiple attempts. There might be an issue with external APIs or AI response generation." + error_message = ( + f"❌ Analysis for '{topic}' failed after multiple attempts. " + "There might be an issue with external APIs or AI response generation." + ) await send_final_message(bot, user_id, error_message, ack_message_id) update_task_status(request_id, 'FAILED') -# src/core/worker.py - -import logging -import re -import time -import threading -import asyncio -from telegram.error import BadRequest -import matplotlib.pyplot as plt -import os -from datetime import datetime - -from src.config import RETRY_ATTEMPTS, RETRY_DELAY_SECONDS -from src.utils.database import get_pending_task, update_task_status, update_task_result, increment_task_attempts -from src.utils.caching import get_cached_result, set_cached_result -from src.services.market_data_api import get_market_data -from src.services.news_api import fetch_relevant_news -from src.core.prompt_builder import build_analyst_prompt -from src.core.cortensor_client import get_ai_analysis -from src.bot.formatter import format_final_message, _clean_ai_output, escape_html - -logger = logging.getLogger(__name__) - -def is_response_valid(response_text: str) -> bool: - """A simple validation layer to check the quality of the AI response.""" - if not response_text or len(response_text) < 20: - logger.warning("Validation failed: AI response is too short or empty.") - return False - return True - -async def send_final_message(bot, user_id, message_text, ack_message_id=None): - """Safely sends a message to the user and deletes acknowledgment message if provided.""" - try: - # Send the final result message - await bot.send_message(user_id, message_text, parse_mode='HTML', disable_web_page_preview=True) - - # Delete the acknowledgment message if provided - if ack_message_id: - try: - await bot.delete_message(chat_id=user_id, message_id=ack_message_id) - logger.info(f"Deleted acknowledgment message {ack_message_id} for user {user_id}") - except Exception as e: - logger.warning(f"Could not delete acknowledgment message {ack_message_id}: {e}") - - return True - except BadRequest as e: - logger.error(f"Failed to send message: {e}") - return False async def worker_loop(bot): @@ -233,8 +327,13 @@ async def worker_loop(bot): logger.critical(f"Critical error in worker loop: {e}", exc_info=True) await asyncio.sleep(5) + def start_background_worker(bot_instance): """Starts the worker in a separate daemon thread.""" - worker_thread = threading.Thread(target=lambda: asyncio.run(worker_loop(bot_instance)), daemon=True) + worker_thread = threading.Thread( + target=lambda: asyncio.run(worker_loop(bot_instance)), + daemon=True + ) worker_thread.start() - return worker_thread \ No newline at end of file + logger.info("Background worker thread initialized.") + return worker_thread diff --git a/apps/Cortensor-AnalystAI/src/main.py b/apps/Cortensor-AnalystAI/src/main.py index 354b933..1704b0e 100644 --- a/apps/Cortensor-AnalystAI/src/main.py +++ b/apps/Cortensor-AnalystAI/src/main.py @@ -48,35 +48,30 @@ def main(): setup_logging() logger = logging.getLogger(__name__) - print("DEBUG: TELEGRAM_BOT_TOKEN loaded as: {}... (first 5 chars)".format(TELEGRAM_BOT_TOKEN[:5] if TELEGRAM_BOT_TOKEN else "None")) - print(f"DEBUG: TELEGRAM_BOT_TOKEN type: {type(TELEGRAM_BOT_TOKEN)}") - print("DEBUG: Inside main() function.") + logger.debug(f"TELEGRAM_BOT_TOKEN loaded: {'Yes' if TELEGRAM_BOT_TOKEN else 'No'}") + logger.info("Starting Analyst Bot...") if not TELEGRAM_BOT_TOKEN: - logger.critical("FATAL: TELEGRAM_BOT_TOKEN is not configured.") - print("DEBUG: TELEGRAM_BOT_TOKEN is NOT configured, exiting.") + logger.critical("FATAL: TELEGRAM_BOT_TOKEN is not configured. Exiting.") return - else: - print("DEBUG: TELEGRAM_BOT_TOKEN is configured, proceeding.") + logger.info("TELEGRAM_BOT_TOKEN configured successfully.") setup_database() - print("DEBUG: Database setup complete.") + logger.info("Database setup complete.") application = Application.builder().token(TELEGRAM_BOT_TOKEN).build() - - print("DEBUG: Telegram Application initialized.") + logger.info("Telegram Application initialized.") scheduler_manager = SchedulerManager() scheduler_manager.start() - - print("DEBUG: Scheduler started.") + logger.info("Scheduler started.") start_background_worker(application.bot) - print("DEBUG: Background worker started.") + logger.info("Background worker started.") start_dca_worker(application.bot) - print("DEBUG: DCA worker started.") + logger.info("DCA worker started.") # --- Register all command handlers --- application.add_handler(CommandHandler("start", start_command)) @@ -100,9 +95,8 @@ def main(): application.add_error_handler(error_handler) - application.run_polling() logger.info("Bot is running with Scheduler and Worker. Press Ctrl+C to stop.") - print("DEBUG: application.run_polling() started (this should block).") + application.run_polling() if __name__ == '__main__': main() \ No newline at end of file diff --git a/apps/Cortensor-AnalystAI/src/services/currency_api.py b/apps/Cortensor-AnalystAI/src/services/currency_api.py new file mode 100644 index 0000000..415f523 --- /dev/null +++ b/apps/Cortensor-AnalystAI/src/services/currency_api.py @@ -0,0 +1,257 @@ +# src/services/currency_api.py + +""" +Real-time Currency Exchange Rate Service +Provides accurate exchange rates with caching to reduce API calls. +""" + +import logging +import time +import requests +from typing import Optional + +from src.config import EXCHANGE_RATE_API_URL, EXCHANGE_RATE_CACHE_DURATION, DEFAULT_API_TIMEOUT + +logger = logging.getLogger(__name__) + +# Cache configuration +_exchange_rate_cache = { + 'rates': {}, + 'timestamp': 0, + 'base_currency': 'USD' +} +CACHE_DURATION_SECONDS = EXCHANGE_RATE_CACHE_DURATION + + +# --- Fallback rates (updated Jan 2026 approximations) --- +# Used when API is unavailable +FALLBACK_RATES = { + 'USD': 1.0, + 'IDR': 16200.0, # Updated from 15000 + 'EUR': 0.92, # Updated from 0.85 + 'JPY': 157.0, # Updated from 110 + 'GBP': 0.79, + 'SGD': 1.35, + 'AUD': 1.58, + 'CAD': 1.44, + 'CNY': 7.30, + 'KRW': 1450.0, + 'INR': 85.0, + 'BTC': 0.000011, # Approximate for crypto + 'ETH': 0.00028, # Approximate for crypto +} + + +def get_exchange_rates(base_currency: str = 'USD', force_refresh: bool = False) -> dict: + """ + Fetch exchange rates from API with caching. + + Args: + base_currency: The base currency for rates (default: USD) + force_refresh: Force refresh from API, ignoring cache + + Returns: + dict: Dictionary of currency codes to exchange rates + """ + global _exchange_rate_cache + + current_time = time.time() + cache_valid = ( + not force_refresh and + _exchange_rate_cache['rates'] and + _exchange_rate_cache['base_currency'] == base_currency and + (current_time - _exchange_rate_cache['timestamp']) < CACHE_DURATION_SECONDS + ) + + if cache_valid: + logger.debug(f"Using cached exchange rates (age: {int(current_time - _exchange_rate_cache['timestamp'])}s)") + return _exchange_rate_cache['rates'] + + try: + logger.info(f"Fetching fresh exchange rates for base currency: {base_currency}") + response = requests.get( + f"{EXCHANGE_RATE_API_URL}/{base_currency}", + timeout=DEFAULT_API_TIMEOUT + ) + response.raise_for_status() + + data = response.json() + rates = data.get('rates', {}) + + if rates: + # Update cache + _exchange_rate_cache = { + 'rates': rates, + 'timestamp': current_time, + 'base_currency': base_currency + } + logger.info(f"Exchange rates updated successfully. {len(rates)} currencies available.") + return rates + else: + logger.warning("API returned empty rates, using fallback") + return FALLBACK_RATES + + except requests.exceptions.RequestException as e: + logger.warning(f"Failed to fetch exchange rates from API: {e}. Using fallback rates.") + return FALLBACK_RATES + except Exception as e: + logger.error(f"Unexpected error fetching exchange rates: {e}. Using fallback rates.") + return FALLBACK_RATES + + +def convert_currency( + amount: float, + from_currency: str, + to_currency: str = 'USD' +) -> Optional[float]: + """ + Convert amount from one currency to another. + + Args: + amount: The amount to convert + from_currency: Source currency code (e.g., 'IDR', 'EUR') + to_currency: Target currency code (default: 'USD') + + Returns: + float: Converted amount, or None if conversion fails + """ + from_currency = from_currency.upper() + to_currency = to_currency.upper() + + if from_currency == to_currency: + return amount + + try: + # Get rates with USD as base + rates = get_exchange_rates('USD') + + # Convert from source currency to USD first + if from_currency == 'USD': + amount_in_usd = amount + elif from_currency in rates: + amount_in_usd = amount / rates[from_currency] + else: + logger.warning(f"Unknown source currency: {from_currency}") + return None + + # Convert from USD to target currency + if to_currency == 'USD': + return amount_in_usd + elif to_currency in rates: + return amount_in_usd * rates[to_currency] + else: + logger.warning(f"Unknown target currency: {to_currency}") + return None + + except Exception as e: + logger.error(f"Currency conversion error: {e}") + return None + + +def get_conversion_rate(from_currency: str, to_currency: str = 'USD') -> Optional[float]: + """ + Get the conversion rate between two currencies. + + Args: + from_currency: Source currency code + to_currency: Target currency code (default: 'USD') + + Returns: + float: Conversion rate (multiply by this to convert), or None if unavailable + """ + from_currency = from_currency.upper() + to_currency = to_currency.upper() + + if from_currency == to_currency: + return 1.0 + + try: + rates = get_exchange_rates('USD') + + if from_currency == 'USD': + from_rate = 1.0 + elif from_currency in rates: + from_rate = rates[from_currency] + else: + return None + + if to_currency == 'USD': + to_rate = 1.0 + elif to_currency in rates: + to_rate = rates[to_currency] + else: + return None + + # Rate to multiply: (to_rate / from_rate) + return to_rate / from_rate + + except Exception as e: + logger.error(f"Error getting conversion rate: {e}") + return None + + +def format_currency(amount: float, currency: str, include_symbol: bool = True) -> str: + """ + Format amount with proper currency symbol and formatting. + + Args: + amount: The amount to format + currency: Currency code + include_symbol: Whether to include currency symbol + + Returns: + str: Formatted currency string + """ + currency = currency.upper() + + currency_config = { + 'USD': {'symbol': '$', 'decimals': 2, 'position': 'before'}, + 'EUR': {'symbol': '€', 'decimals': 2, 'position': 'before'}, + 'GBP': {'symbol': '£', 'decimals': 2, 'position': 'before'}, + 'JPY': {'symbol': '¥', 'decimals': 0, 'position': 'before'}, + 'IDR': {'symbol': 'Rp', 'decimals': 0, 'position': 'before'}, + 'SGD': {'symbol': 'S$', 'decimals': 2, 'position': 'before'}, + 'AUD': {'symbol': 'A$', 'decimals': 2, 'position': 'before'}, + 'CAD': {'symbol': 'C$', 'decimals': 2, 'position': 'before'}, + 'CNY': {'symbol': '¥', 'decimals': 2, 'position': 'before'}, + 'KRW': {'symbol': '₩', 'decimals': 0, 'position': 'before'}, + 'INR': {'symbol': '₹', 'decimals': 2, 'position': 'before'}, + } + + config = currency_config.get(currency, {'symbol': currency, 'decimals': 2, 'position': 'after'}) + + # Format the number + if config['decimals'] == 0: + formatted_amount = f"{amount:,.0f}" + else: + formatted_amount = f"{amount:,.{config['decimals']}f}" + + if not include_symbol: + return formatted_amount + + # Add symbol + if config['position'] == 'before': + return f"{config['symbol']}{formatted_amount}" + else: + return f"{formatted_amount} {config['symbol']}" + + +def get_supported_currencies() -> list: + """ + Get list of supported currencies. + + Returns: + list: List of supported currency codes + """ + return list(FALLBACK_RATES.keys()) + + +def clear_rate_cache(): + """Clear the exchange rate cache to force fresh fetch.""" + global _exchange_rate_cache + _exchange_rate_cache = { + 'rates': {}, + 'timestamp': 0, + 'base_currency': 'USD' + } + logger.info("Exchange rate cache cleared") diff --git a/apps/Cortensor-AnalystAI/src/services/market_data_api.py b/apps/Cortensor-AnalystAI/src/services/market_data_api.py index 30c15ec..6f5d641 100644 --- a/apps/Cortensor-AnalystAI/src/services/market_data_api.py +++ b/apps/Cortensor-AnalystAI/src/services/market_data_api.py @@ -4,15 +4,17 @@ import requests import yfinance as yf # Import yfinance di sini from thefuzz import fuzz -from src.config import COINGECKO_API_KEY, FMP_API_KEY +from src.config import ( + COINGECKO_API_KEY, + FMP_API_KEY, + COINGECKO_API_URL, + FMP_API_URL, + DEFAULT_API_TIMEOUT +) from datetime import datetime, timedelta logger = logging.getLogger(__name__) -# --- API URLs --- -COINGECKO_API_URL = "https://api.coingecko.com/api/v3" -FMP_API_URL = "https://financialmodelingprep.com/api/v3" - def calculate_price_change(prices: list) -> float: """Calculates percentage change between the first and last price in a list.""" if not prices or len(prices) < 2: @@ -28,7 +30,7 @@ def get_crypto_data(topic: str) -> dict | None: """Fetches crypto data from CoinGecko, including historical changes.""" headers = {"x-cg-demo-api-key": COINGECKO_API_KEY, "Accept": "application/json"} try: - search_response = requests.get(f"{COINGECKO_API_URL}/search", headers=headers, params={'query': topic}, timeout=10) + search_response = requests.get(f"{COINGECKO_API_URL}/search", headers=headers, params={'query': topic}, timeout=DEFAULT_API_TIMEOUT) search_data = search_response.json() if not search_data.get('coins'): return None @@ -51,7 +53,7 @@ def get_crypto_data(topic: str) -> dict | None: coin_id = best_match_coin['id'] logger.info(f"Found crypto '{best_match_coin['name']}' ({best_match_coin['symbol']}) with ID: {coin_id}") - market_response = requests.get(f"{COINGECKO_API_URL}/coins/{coin_id}", headers=headers, timeout=10) + market_response = requests.get(f"{COINGECKO_API_URL}/coins/{coin_id}", headers=headers, timeout=DEFAULT_API_TIMEOUT) market_data = market_response.json() data = market_data.get('market_data', {}) @@ -66,13 +68,13 @@ def get_crypto_data(topic: str) -> dict | None: try: # Ambil data 7 hari url_7d = f"{COINGECKO_API_URL}/coins/{coin_id}/market_chart?vs_currency=usd&days=7" - response_7d = requests.get(url_7d, headers=headers, timeout=10) + response_7d = requests.get(url_7d, headers=headers, timeout=DEFAULT_API_TIMEOUT) if response_7d.status_code == 200: prices_7d = [p[1] for p in response_7d.json().get('prices', [])] # Ambil data 30 hari url_30d = f"{COINGECKO_API_URL}/coins/{coin_id}/market_chart?vs_currency=usd&days=30" - response_30d = requests.get(url_30d, headers=headers, timeout=10) + response_30d = requests.get(url_30d, headers=headers, timeout=DEFAULT_API_TIMEOUT) if response_30d.status_code == 200: prices_30d = [p[1] for p in response_30d.json().get('prices', [])] @@ -106,8 +108,12 @@ def get_stock_data_from_fmp(symbol: str) -> dict | None: return None try: logger.info(f"Fetching stock data for exact symbol '{symbol.upper()}' from FMP.") - quote_url = f"{FMP_API_URL}/quote/{symbol.upper()}?apikey={FMP_API_KEY}" - quote_res = requests.get(quote_url, timeout=10) + quote_url = f"{FMP_API_URL}/quote" + quote_res = requests.get( + quote_url, + params={"symbol": symbol.upper(), "apikey": FMP_API_KEY}, + timeout=DEFAULT_API_TIMEOUT + ) quote_data_list = quote_res.json() if not quote_data_list: @@ -116,6 +122,35 @@ def get_stock_data_from_fmp(symbol: str) -> dict | None: quote_data = quote_data_list[0] if isinstance(quote_data_list, list) else quote_data_list + def safe_float(value): + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + cleaned = value.replace(',', '').strip() + if not cleaned: + return None + try: + return float(cleaned) + except ValueError: + return None + return None + + symbol_value = quote_data.get('symbol') or symbol.upper() + name_value = ( + quote_data.get('name') + or quote_data.get('companyName') + or symbol_value + ) + + current_price = safe_float(quote_data.get('price')) + price_change_pct = safe_float(quote_data.get('changesPercentage')) + trading_volume = safe_float(quote_data.get('volume')) + market_cap = safe_float(quote_data.get('marketCap')) + year_high = safe_float(quote_data.get('yearHigh')) + year_low = safe_float(quote_data.get('yearLow')) + pe_ratio_val = safe_float(quote_data.get('pe')) + eps_val = safe_float(quote_data.get('eps')) + # --- Ambil data historis untuk saham dari yfinance --- price_change_7d_pct = 0.0 price_change_30d_pct = 0.0 @@ -137,12 +172,16 @@ def get_stock_data_from_fmp(symbol: str) -> dict | None: logger.warning(f"Could not fetch historical stock data for '{symbol}' from yfinance: {hist_e}") return { - "type": "Stock", "name": quote_data.get('name'), "symbol": quote_data.get('symbol'), - "company_name": quote_data.get('name'), - "price_change_pct": quote_data.get('changesPercentage'), "current_price": quote_data.get('price'), - "trading_volume": quote_data.get('volume'), "high_52w": quote_data.get('yearHigh'), - "low_52w": quote_data.get('yearLow'), "market_cap": quote_data.get('marketCap'), - "pe_ratio": quote_data.get('pe', 'N/A'), "eps_ttm": quote_data.get('eps', 'N/A'), + "type": "Stock", "name": name_value, "symbol": symbol_value, + "company_name": name_value, + "price_change_pct": price_change_pct if price_change_pct is not None else 0.0, + "current_price": current_price if current_price is not None else 0.0, + "trading_volume": trading_volume if trading_volume is not None else 0.0, + "high_52w": year_high, + "low_52w": year_low, + "market_cap": market_cap if market_cap is not None else 0.0, + "pe_ratio": pe_ratio_val if pe_ratio_val is not None else 'N/A', + "eps_ttm": eps_val if eps_val is not None else 'N/A', "dividend_yield": 0.0, "revenue_ttm": None, "debt_equity_ratio": None, "price_change_7d_pct": price_change_7d_pct, # Tambahkan ini "price_change_30d_pct": price_change_30d_pct, # Tambahkan ini diff --git a/apps/Cortensor-AnalystAI/src/services/news_api.py b/apps/Cortensor-AnalystAI/src/services/news_api.py index f3f6246..355c955 100644 --- a/apps/Cortensor-AnalystAI/src/services/news_api.py +++ b/apps/Cortensor-AnalystAI/src/services/news_api.py @@ -4,16 +4,24 @@ import requests import yfinance as yf from thefuzz import fuzz -from src.config import NEWS_API_KEY, FMP_API_KEY +from src.config import ( + NEWS_API_KEY, + FMP_API_KEY, + COINGECKO_API_URL, + FMP_API_URL, + NEWSAPI_URL, + DEFAULT_API_TIMEOUT, + FMP_PROFILE_TIMEOUT, + NEWS_LOOKBACK_DAYS, + NEWS_EXTENDED_LOOKBACK_DAYS, + NEWS_PAGE_SIZE, + NEWS_FUZZY_MATCH_THRESHOLD +) from datetime import datetime, timedelta logger = logging.getLogger(__name__) -# --- API URLs --- -COINGECKO_API_URL = "https://api.coingecko.com/api/v3" -FMP_API_URL = "https://financialmodelingprep.com/api/v3" - -def filter_news_by_topic(articles: list[dict], topic: str, symbol: str, min_score: int = 75) -> list[dict]: # Increased min_score +def filter_news_by_topic(articles: list[dict], topic: str, symbol: str, min_score: int = NEWS_FUZZY_MATCH_THRESHOLD) -> list[dict]: """ Filters a list of articles to include only those highly relevant to the topic/symbol, using fuzzy matching on title and description. @@ -63,7 +71,7 @@ def filter_news_by_topic(articles: list[dict], topic: str, symbol: str, min_scor return relevant_articles -def get_news_from_yahoo(topic: str, limit: int = 5) -> list[dict] | None: +def get_news_from_yahoo(topic: str, limit: int = NEWS_PAGE_SIZE) -> list[dict] | None: """ Fetches news from Yahoo Finance. Specifically for STOCKS. """ @@ -94,7 +102,7 @@ def get_news_from_yahoo(topic: str, limit: int = 5) -> list[dict] | None: logger.error(f"Error fetching from yfinance for '{topic}': {e}.") return [] -def get_crypto_news_from_newsapi(topic: str, symbol: str, limit: int = 5) -> list[dict]: +def get_crypto_news_from_newsapi(topic: str, symbol: str, limit: int = NEWS_PAGE_SIZE) -> list[dict]: """ Fetches crypto-focused news from NewsAPI. Uses both topic name and symbol in the query for better relevance. @@ -108,15 +116,15 @@ def get_crypto_news_from_newsapi(topic: str, symbol: str, limit: int = 5) -> lis # Use a narrower date range to get fresher results. today = datetime.now() # NewsAPI 'from' parameter is date only. - from_date = (today - timedelta(days=2)).strftime('%Y-%m-%d') # Look back 2 days for fresher news + from_date = (today - timedelta(days=NEWS_LOOKBACK_DAYS)).strftime('%Y-%m-%d') query_specific = f'("{topic}" OR "{symbol}") AND (cryptocurrency OR crypto OR blockchain OR web3 OR coin)' - url_specific = f"https://newsapi.org/v2/everything?q={query_specific}&language=en&pageSize={limit}&sortBy=relevancy&from={from_date}&apiKey={NEWS_API_KEY}" + url_specific = f"{NEWSAPI_URL}/everything?q={query_specific}&language=en&pageSize={limit}&sortBy=relevancy&from={from_date}&apiKey={NEWS_API_KEY}" articles = [] try: logger.info(f"Attempting crypto news fetch from NewsAPI with specific query: {query_specific} from {from_date}.") - response = requests.get(url_specific, timeout=10) + response = requests.get(url_specific, timeout=DEFAULT_API_TIMEOUT) logger.debug(f"NewsAPI (crypto specific) response status: {response.status_code}") response.raise_for_status() articles = response.json().get("articles", []) @@ -124,10 +132,10 @@ def get_crypto_news_from_newsapi(topic: str, symbol: str, limit: int = 5) -> lis # If few or no specific articles are found within 2 days, try a slightly broader date range (e.g., 7 days) if not articles and limit > 0: - logger.warning(f"No specific crypto articles for '{topic}' in last 2 days. Trying last 7 days.") - from_date_7d = (today - timedelta(days=7)).strftime('%Y-%m-%d') - url_broad_7d = f"https://newsapi.org/v2/everything?q={query_specific}&language=en&pageSize={limit}&sortBy=relevancy&from={from_date_7d}&apiKey={NEWS_API_KEY}" - response_broad_7d = requests.get(url_broad_7d, timeout=10) + logger.warning(f"No specific crypto articles for '{topic}' in last {NEWS_LOOKBACK_DAYS} days. Trying last {NEWS_EXTENDED_LOOKBACK_DAYS} days.") + from_date_7d = (today - timedelta(days=NEWS_EXTENDED_LOOKBACK_DAYS)).strftime('%Y-%m-%d') + url_broad_7d = f"{NEWSAPI_URL}/everything?q={query_specific}&language=en&pageSize={limit}&sortBy=relevancy&from={from_date_7d}&apiKey={NEWS_API_KEY}" + response_broad_7d = requests.get(url_broad_7d, timeout=DEFAULT_API_TIMEOUT) logger.debug(f"NewsAPI (crypto 7-day broad) response status: {response_broad_7d.status_code}") response_broad_7d.raise_for_status() articles.extend(response_broad_7d.json().get("articles", [])) # Extend, don't overwrite @@ -169,7 +177,7 @@ def get_crypto_news_from_newsapi(topic: str, symbol: str, limit: int = 5) -> lis logger.debug(f"NewsAPI formatted {len(formatted_articles)} valid articles after filtering.") return formatted_articles -def get_stock_news_fallback(topic: str, symbol: str, limit: int = 5) -> list[dict]: +def get_stock_news_fallback(topic: str, symbol: str, limit: int = NEWS_PAGE_SIZE) -> list[dict]: """ Fallback for stock news if yfinance fails. Uses company name and symbol with NewsAPI. """ @@ -185,8 +193,12 @@ def get_stock_news_fallback(topic: str, symbol: str, limit: int = 5) -> list[dic # Try to get company name from FMP if topic is a symbol symbol_for_fmp = topic.upper().replace(".JK", "") # Clean for FMP (e.g., remove .JK for Indonesian stocks) logger.debug(f"Trying to get company profile from FMP for symbol: {symbol_for_fmp}") - url = f"https://financialmodelingprep.com/api/v3/profile/{symbol_for_fmp}?apikey={FMP_API_KEY}" - response = requests.get(url, timeout=5) + url = f"{FMP_API_URL}/profile" + response = requests.get( + url, + params={"symbol": symbol_for_fmp, "apikey": FMP_API_KEY}, + timeout=FMP_PROFILE_TIMEOUT + ) profile_data = response.json() if profile_data and profile_data[0].get('companyName'): company_name = profile_data[0]['companyName'] @@ -196,14 +208,14 @@ def get_stock_news_fallback(topic: str, symbol: str, limit: int = 5) -> list[dic logger.info(f"Attempting general news fetch for '{company_name}' / '{resolved_symbol}' from NewsAPI (stock fallback).") today = datetime.now() - from_date = (today - timedelta(days=2)).strftime('%Y-%m-%d') # Look back 2 days for fresher news + from_date = (today - timedelta(days=NEWS_LOOKBACK_DAYS)).strftime('%Y-%m-%d') query_stock_fallback = f'("{company_name}" OR "{resolved_symbol}")' - url_stock_fallback = f"https://newsapi.org/v2/everything?q={query_stock_fallback}&language=en&pageSize={limit}&sortBy=relevancy&from={from_date}&apiKey={NEWS_API_KEY}" + url_stock_fallback = f"{NEWSAPI_URL}/everything?q={query_stock_fallback}&language=en&pageSize={limit}&sortBy=relevancy&from={from_date}&apiKey={NEWS_API_KEY}" articles = [] try: - response = requests.get(url_stock_fallback, timeout=10) + response = requests.get(url_stock_fallback, timeout=DEFAULT_API_TIMEOUT) logger.debug(f"NewsAPI (stock fallback) response status: {response.status_code}") response.raise_for_status() articles = response.json().get("articles", []) diff --git a/apps/Cortensor-AnalystAI/src/utils/database.py b/apps/Cortensor-AnalystAI/src/utils/database.py index a680f20..847f3b5 100644 --- a/apps/Cortensor-AnalystAI/src/utils/database.py +++ b/apps/Cortensor-AnalystAI/src/utils/database.py @@ -1,6 +1,13 @@ import os from tinydb import TinyDB, Query from datetime import datetime +from src.config import ( + DEFAULT_TIMEZONE, + DEFAULT_CURRENCY, + CACHE_EXPIRATION_MINUTES, + DEFAULT_ALERTS_THRESHOLD, + DEFAULT_DATA_RETENTION_DAYS +) # Ensure the data directory exists when this module is imported os.makedirs("data", exist_ok=True) @@ -122,18 +129,18 @@ def get_user_settings(user_id: int) -> dict: """Retrieves user settings, returns default settings if not found.""" settings = user_settings_db.get(UserSettings.user_id == user_id) if not settings: - # Default settings + # Default settings from config default_settings = { 'user_id': user_id, - 'timezone': 'Asia/Jakarta', - 'currency': 'USD', + 'timezone': DEFAULT_TIMEZONE, + 'currency': DEFAULT_CURRENCY, 'language': 'en', 'notifications': True, - 'alerts_threshold': 5.0, + 'alerts_threshold': DEFAULT_ALERTS_THRESHOLD, 'charts_enabled': True, - 'cache_duration': 60, + 'cache_duration': CACHE_EXPIRATION_MINUTES, 'export_format': 'HTML', - 'data_retention_days': 30, + 'data_retention_days': DEFAULT_DATA_RETENTION_DAYS, 'privacy_mode': False, 'created_at': datetime.now().isoformat() } diff --git a/apps/Cortensor-Price-Analyzer/STATUS.md b/apps/Cortensor-Price-Analyzer/STATUS.md index 5c71f60..28c1add 100644 --- a/apps/Cortensor-Price-Analyzer/STATUS.md +++ b/apps/Cortensor-Price-Analyzer/STATUS.md @@ -1,7 +1,15 @@ # Roadmap +## Completed (January 2026) +- ✅ Improved Cortensor timeout handling with exponential backoff (env-configurable timeout, 3 retries) +- ✅ Added in-memory caching layer for market data, news, and AI analysis +- ✅ Enhanced error handling with retriable error codes detection +- ✅ Added elapsed time indicator during analysis +- ✅ Added "Cached" badge to indicate fast responses from cache +- ✅ Better user feedback during long AI synthesis operations + ## Short-Term -- Reduce latency on multi-provider fetches (optimize TwelveData output size, add caching) +- Reduce latency on multi-provider fetches (optimize TwelveData output size) - Reinstate summarize API with updated Cortensor workflow - Ship quick tour/onboarding copy for first-time users @@ -11,6 +19,6 @@ - Provide exportable PDF/CSV market briefings for teams ## Known Issues -- Cortensor AI occasionally returns 408 timeouts; fallback narrative engages but response takes ~60s +- Cortensor AI occasionally returns timeouts; improved retry logic now handles most cases - Commodities relying on TwelveData can respond slowly when Stooq coverage is thin - Summarize endpoint currently disabled pending service redesign diff --git a/apps/Cortensor-Price-Analyzer/solrc b/apps/Cortensor-Price-Analyzer/solrc deleted file mode 100644 index 0b571c3..0000000 Binary files a/apps/Cortensor-Price-Analyzer/solrc and /dev/null differ diff --git a/apps/Cortensor-Price-Analyzer/src/app/api/market-tickers/route.ts b/apps/Cortensor-Price-Analyzer/src/app/api/market-tickers/route.ts index f961d16..db0e8b8 100644 --- a/apps/Cortensor-Price-Analyzer/src/app/api/market-tickers/route.ts +++ b/apps/Cortensor-Price-Analyzer/src/app/api/market-tickers/route.ts @@ -121,7 +121,7 @@ const twelvedataStocksKey = process.env.TWELVEDATA_STOCKS_API_KEY ?? null; const alphaVantagePrimaryKey = process.env.ALPHA_VANTAGE_API_KEY; const alphaVantageForexKey = process.env.ALPHA_VANTAGE_FOREX_API_KEY ?? alphaVantagePrimaryKey; const alphaVantageCommodityKey = alphaVantagePrimaryKey ?? alphaVantageForexKey; -const alphaVantageRetryKey = process.env.ALPHA_VANTAGE_RETRY_API_KEY ?? 'BWAHOQOJKEV0ER1A'; +const alphaVantageRetryKey = process.env.ALPHA_VANTAGE_RETRY_API_KEY ?? alphaVantagePrimaryKey; const marketstackKey = process.env.MARKETSTACK_API_KEY; const massiveKey = process.env.MASSIVE_API_KEY; const coingeckoKey = process.env.COINGECKO_API_KEY; @@ -150,12 +150,13 @@ type CategorySpec = { }; const CATEGORY_SPECS: Record<WatchlistKey, CategorySpec> = { - equities: { items: EQUITY_WATCHLIST, providers: ['twelvedata', 'stooq'], ttlMs: 5 * 60 * 1000 }, + equities: { items: EQUITY_WATCHLIST, providers: ['twelvedata', 'stooq', 'marketstack', 'massive'], ttlMs: 5 * 60 * 1000 }, crypto: { items: CRYPTO_WATCHLIST, providers: ['coingecko', 'twelvedata'], ttlMs: 10 * 60 * 1000 }, - forex: { items: FOREX_WATCHLIST, providers: ['alphavantage', 'twelvedata'], ttlMs: 60 * 60 * 1000 }, + forex: { items: FOREX_WATCHLIST, providers: ['twelvedata', 'alphavantage'], ttlMs: 60 * 60 * 1000 }, commodities: { items: COMMODITY_WATCHLIST, - providers: ['alphavantage', 'twelvedata', 'stooq', 'marketstack', 'massive'], + // Prioritize twelvedata and stooq first (no strict rate limits), alphavantage as last fallback + providers: ['twelvedata', 'stooq', 'alphavantage'], ttlMs: 15 * 60 * 1000, requireComplete: true, }, @@ -495,6 +496,19 @@ type AlphaCommodityQuote = { refreshedAt: string | null; }; +// Rate limiting: Alpha Vantage free tier allows 1 request per second +const ALPHA_VANTAGE_RATE_LIMIT_MS = 1200; // 1.2 seconds to be safe +let lastAlphaVantageRequest = 0; + +async function alphaVantageRateLimitDelay(): Promise<void> { + const now = Date.now(); + const elapsed = now - lastAlphaVantageRequest; + if (elapsed < ALPHA_VANTAGE_RATE_LIMIT_MS) { + await new Promise((resolve) => setTimeout(resolve, ALPHA_VANTAGE_RATE_LIMIT_MS - elapsed)); + } + lastAlphaVantageRequest = Date.now(); +} + async function fetchAlphaVantageCommodities(list: WatchItem[]): Promise<ProviderResult> { if (!alphaVantageCommodityKey) { throw new Error('ALPHA_VANTAGE_API_KEY (or ALPHA_VANTAGE_FOREX_API_KEY) is required for commodity quotes.'); @@ -506,21 +520,40 @@ async function fetchAlphaVantageCommodities(list: WatchItem[]): Promise<Provider for (const item of list) { const spec = ALPHA_COMMODITY_SPECS[item.symbol]; if (!spec) { - throw new Error(`Missing Alpha Vantage commodity mapping for ${item.symbol}`); + // Skip items without mapping instead of throwing + console.warn(`Missing Alpha Vantage commodity mapping for ${item.symbol}, skipping`); + items.push({ ...item, currency: item.currency ?? 'USD' }); + continue; } - const quote: AlphaCommodityQuote = - spec.mode === 'dailySeries' - ? await loadAlphaDailySeriesQuote(spec.symbol, alphaVantageCommodityKey) - : await loadAlphaMacroCommodityQuote(spec.function, spec.interval, alphaVantageCommodityKey); - - timestampCandidates.push(quote.refreshedAt); - items.push({ - ...item, - currency: item.currency ?? 'USD', - price: quote.price, - changePercent: quote.changePercent, - }); + try { + // Apply rate limiting before each request + await alphaVantageRateLimitDelay(); + + const quote: AlphaCommodityQuote = + spec.mode === 'dailySeries' + ? await loadAlphaDailySeriesQuote(spec.symbol, alphaVantageCommodityKey) + : await loadAlphaMacroCommodityQuote(spec.function, spec.interval, alphaVantageCommodityKey); + + timestampCandidates.push(quote.refreshedAt); + items.push({ + ...item, + currency: item.currency ?? 'USD', + price: quote.price, + changePercent: quote.changePercent, + }); + } catch (error) { + // If rate limited, add item without price and continue + const message = error instanceof Error ? error.message : String(error); + if (message.includes('rate limit') || message.includes('Thank you for using Alpha Vantage')) { + console.warn(`Alpha Vantage rate limited for ${item.symbol}, skipping remaining items`); + // Add remaining items without prices + items.push({ ...item, currency: item.currency ?? 'USD' }); + // Don't continue fetching - rate limit affects all requests + break; + } + throw error; + } } return { diff --git a/apps/Cortensor-Price-Analyzer/src/app/api/price-analyzer/route.ts b/apps/Cortensor-Price-Analyzer/src/app/api/price-analyzer/route.ts index be17d9e..7f5f5f3 100644 --- a/apps/Cortensor-Price-Analyzer/src/app/api/price-analyzer/route.ts +++ b/apps/Cortensor-Price-Analyzer/src/app/api/price-analyzer/route.ts @@ -2,23 +2,46 @@ import { NextRequest, NextResponse } from 'next/server'; import { MarketDataService, MarketDataError } from '@/lib/marketDataService'; import { NewsService } from '@/lib/newsService'; import { CortensorService } from '@/lib/cortensorService'; +import { marketCache } from '@/lib/cacheService'; +import type { CacheTTL } from '@/lib/cacheService'; import type { AnalysisHorizon, AnalyzerResponse, AssetType, MarketAnalysisContext } from '@/lib/marketTypes'; +function ttlTypeForAsset(assetType: AssetType): keyof CacheTTL { + switch (assetType) { + case 'equity': + return 'equity'; + case 'etf': + return 'etf'; + case 'crypto': + return 'crypto'; + case 'forex': + return 'forex'; + case 'commodity': + return 'commodity'; + default: + return 'default'; + } +} + interface AnalyzerRequestBody { ticker?: string; assetType?: AssetType; horizon?: AnalysisHorizon; + skipCache?: boolean; } const DEFAULT_ASSET_TYPE: AssetType = 'equity'; const DEFAULT_HORIZON: AnalysisHorizon = '3M'; export async function POST(request: NextRequest) { + const startTime = Date.now(); + try { const body = (await request.json()) as AnalyzerRequestBody; const ticker = body.ticker?.trim().toUpperCase(); const assetType: AssetType = body.assetType || DEFAULT_ASSET_TYPE; const horizon: AnalysisHorizon = body.horizon || DEFAULT_HORIZON; + const skipCache = body.skipCache === true; if (!ticker) { return NextResponse.json({ error: 'Ticker symbol is required' }, { status: 400 }); @@ -32,18 +55,50 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: `Unsupported horizon: ${horizon}` }, { status: 400 }); } + // Check for cached full response first (unless skipCache is set) + const fullCacheKey = marketCache.generateKey('full-analysis', ticker, assetType, horizon); + if (!skipCache) { + const cachedResponse = marketCache.get<AnalyzerResponse>(fullCacheKey); + if (cachedResponse) { + console.log(`[API] Full cache hit for ${ticker} (${Date.now() - startTime}ms)`); + return NextResponse.json({ + success: true, + data: cachedResponse, + cached: true, + timing: Date.now() - startTime, + }); + } + } + const marketDataService = new MarketDataService(); const newsService = new NewsService(); const cortensorService = new CortensorService(); - const context = await marketDataService.buildContext({ ticker, assetType, horizon }); + // Fetch market data with caching + const contextCacheKey = marketCache.generateKey('context', ticker, assetType, horizon); + let context: MarketAnalysisContext; + + const cachedContext = skipCache ? null : marketCache.get<MarketAnalysisContext>(contextCacheKey); + if (cachedContext) { + console.log(`[API] Context cache hit for ${ticker}`); + context = cachedContext; + } else { + context = await marketDataService.buildContext({ ticker, assetType, horizon }); + marketCache.set(contextCacheKey, context, ttlTypeForAsset(assetType)); + } - const news = await newsService.fetchLatestNews({ - ticker, - companyName: context.snapshot.companyName, - assetType, - horizon, - }); + // Fetch news with caching + const newsCacheKey = marketCache.generateKey('news', ticker, assetType); + const news = await marketCache.getOrFetch( + newsCacheKey, + () => newsService.fetchLatestNews({ + ticker, + companyName: context.snapshot.companyName, + assetType, + horizon, + }), + 'news', + ); const enrichedContext: MarketAnalysisContext = { ...context, @@ -62,7 +117,18 @@ export async function POST(request: NextRequest) { ai, }; - return NextResponse.json({ success: true, data: response }); + // Cache the full response + marketCache.set(fullCacheKey, response, ttlTypeForAsset(assetType)); + + const timing = Date.now() - startTime; + console.log(`[API] Analysis complete for ${ticker} (${timing}ms)`); + + return NextResponse.json({ + success: true, + data: response, + cached: false, + timing, + }); } catch (error) { if (error instanceof MarketDataError) { console.warn('Market data request rejected:', error.message); diff --git a/apps/Cortensor-Price-Analyzer/src/components/PriceAnalyzerForm.tsx b/apps/Cortensor-Price-Analyzer/src/components/PriceAnalyzerForm.tsx index 3ee66e4..3005bee 100644 --- a/apps/Cortensor-Price-Analyzer/src/components/PriceAnalyzerForm.tsx +++ b/apps/Cortensor-Price-Analyzer/src/components/PriceAnalyzerForm.tsx @@ -300,7 +300,7 @@ const defaultSteps: LoadingStep[] = [ { id: 'ai', title: 'AI Synthesis', - description: 'Generating institutional-grade narrative with Cortensor', + description: 'Generating institutional-grade narrative with Cortensor (may take a few minutes)', icon: Brain, status: 'pending', }, @@ -318,6 +318,8 @@ export default function PriceAnalyzerForm() { const [steps, setSteps] = useState<LoadingStep[]>(defaultSteps); const [showHistory, setShowHistory] = useState(false); const [historyCount, setHistoryCount] = useState(0); + const [elapsedTime, setElapsedTime] = useState(0); + const [wasCached, setWasCached] = useState(false); const historySeries = useMemo<CandleWithClose[]>(() => { if (!result) return []; @@ -353,9 +355,25 @@ export default function PriceAnalyzerForm() { } }, [ticker]); + // Timer for elapsed time during loading + useEffect(() => { + let interval: NodeJS.Timeout | null = null; + if (isLoading) { + setElapsedTime(0); + interval = setInterval(() => { + setElapsedTime((prev) => prev + 1); + }, 1000); + } + return () => { + if (interval) clearInterval(interval); + }; + }, [isLoading]); + const resetSteps = () => { setSteps(defaultSteps.map((step: LoadingStep) => ({ ...step, status: 'pending' }))); setProgress(0); + setElapsedTime(0); + setWasCached(false); }; const updateStep = (stepId: string, status: LoadingStep['status'], completedIndex?: number) => { @@ -391,23 +409,27 @@ export default function PriceAnalyzerForm() { }); if (!response.ok) { - throw new Error(`Analysis failed: ${response.statusText}`); + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData.error || `Analysis failed: ${response.statusText}`; + throw new Error(errorMessage); } const payload = await response.json(); const data: AnalyzerResponse = payload.data; + const isCached = payload.cached === true; updateStep('technicals', 'completed', 1); updateStep('fundamentals', 'active'); - await new Promise((resolve) => setTimeout(resolve, 250)); + await new Promise((resolve) => setTimeout(resolve, isCached ? 50 : 250)); updateStep('fundamentals', 'completed', 2); updateStep('ai', 'active'); - await new Promise((resolve) => setTimeout(resolve, 200)); + await new Promise((resolve) => setTimeout(resolve, isCached ? 50 : 200)); updateStep('ai', 'completed', 3); setResult(data); + setWasCached(isCached); setProgress(100); HistoryService.saveToHistory({ report: data }); @@ -633,19 +655,31 @@ export default function PriceAnalyzerForm() { {isLoading && ( <Card className="border-blue-200 bg-blue-50/60"> <CardHeader> - <CardTitle className="flex items-center space-x-2 text-blue-700"> - <Loader2 className="h-5 w-5 animate-spin" /> - <span>Pipeline Progress</span> + <CardTitle className="flex items-center justify-between text-blue-700"> + <div className="flex items-center space-x-2"> + <Loader2 className="h-5 w-5 animate-spin" /> + <span>Pipeline Progress</span> + </div> + <div className="flex items-center space-x-2 text-sm font-normal"> + <Clock className="h-4 w-4" /> + <span>{elapsedTime}s</span> + </div> </CardTitle> <Progress value={progress} className="h-2" /> </CardHeader> <CardContent className="space-y-3"> <div className="flex items-start gap-3 rounded-md border border-blue-200 bg-white/80 p-3 text-sm text-slate-700"> <Info className="mt-0.5 h-4 w-4 text-blue-600" /> - <p> - Technical scan and AI synthesis may take 1–3 minutes when the network is busy. Hang tight—we'll finalize - the analysis as soon as the data pipeline completes. - </p> + <div> + <p> + AI synthesis may take a few minutes when the network is busy. We'll handle transient failures automatically. + </p> + {elapsedTime > 30 && ( + <p className="mt-1 text-xs text-blue-600"> + Still working... The AI service might be under heavy load. Thank you for your patience. + </p> + )} + </div> </div> {steps.map((step: LoadingStep) => ( <div @@ -691,7 +725,7 @@ export default function PriceAnalyzerForm() { <Card className="shadow-sm border-slate-200"> <CardHeader className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div className="space-y-1"> - <div className="flex items-center gap-2"> + <div className="flex items-center gap-2 flex-wrap"> <Badge variant="secondary" className="uppercase tracking-wide"> {result.meta.assetType} </Badge> @@ -699,6 +733,12 @@ export default function PriceAnalyzerForm() { {result.ai.confidence && ( <Badge variant="outline">Confidence: {result.ai.confidence}</Badge> )} + {wasCached && ( + <Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200"> + <Zap className="h-3 w-3 mr-1" /> + Cached + </Badge> + )} </div> <h2 className="text-xl font-bold text-slate-900"> {result.meta.ticker} diff --git a/apps/Cortensor-Price-Analyzer/src/lib/cacheService.ts b/apps/Cortensor-Price-Analyzer/src/lib/cacheService.ts new file mode 100644 index 0000000..aa52370 --- /dev/null +++ b/apps/Cortensor-Price-Analyzer/src/lib/cacheService.ts @@ -0,0 +1,212 @@ +/** + * Simple in-memory cache with TTL support for market data. + * Reduces API calls and improves response times. + */ + +interface CacheEntry<T> { + data: T; + expiresAt: number; + createdAt: number; +} + +interface CacheStats { + hits: number; + misses: number; + size: number; +} + +type CacheTTL = { + /** Equities: 5 minutes */ + equity: number; + /** ETFs: 5 minutes */ + etf: number; + /** Crypto: 2 minutes (more volatile) */ + crypto: number; + /** Forex: 3 minutes */ + forex: number; + /** Commodities: 5 minutes */ + commodity: number; + /** News: 10 minutes */ + news: number; + /** AI Analysis: 15 minutes */ + ai: number; + /** Default: 5 minutes */ + default: number; +}; + +const DEFAULT_TTL: CacheTTL = { + equity: 5 * 60 * 1000, + etf: 5 * 60 * 1000, + crypto: 2 * 60 * 1000, + forex: 3 * 60 * 1000, + commodity: 5 * 60 * 1000, + news: 10 * 60 * 1000, + ai: 15 * 60 * 1000, + default: 5 * 60 * 1000, +}; + +class CacheService { + private cache: Map<string, CacheEntry<unknown>> = new Map(); + private stats: CacheStats = { hits: 0, misses: 0, size: 0 }; + private maxSize: number; + private ttlConfig: CacheTTL; + + constructor(maxSize = 500, ttlConfig: Partial<CacheTTL> = {}) { + this.maxSize = maxSize; + this.ttlConfig = { ...DEFAULT_TTL, ...ttlConfig }; + } + + /** + * Generate cache key from components + */ + generateKey(prefix: string, ...parts: (string | number | undefined)[]): string { + const sanitized = parts + .filter((p): p is string | number => p !== undefined) + .map((p) => String(p).toLowerCase().trim()) + .join(':'); + return `${prefix}:${sanitized}`; + } + + /** + * Get item from cache if valid + */ + get<T>(key: string): T | null { + const entry = this.cache.get(key) as CacheEntry<T> | undefined; + + if (!entry) { + this.stats.misses++; + return null; + } + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + this.stats.misses++; + this.stats.size = this.cache.size; + return null; + } + + this.stats.hits++; + return entry.data; + } + + /** + * Set item in cache with TTL + */ + set<T>(key: string, data: T, ttlType: keyof CacheTTL = 'default'): void { + // Evict oldest entries if at capacity + if (this.cache.size >= this.maxSize) { + this.evictOldest(Math.ceil(this.maxSize * 0.1)); + } + + const ttl = this.ttlConfig[ttlType]; + const now = Date.now(); + + this.cache.set(key, { + data, + createdAt: now, + expiresAt: now + ttl, + }); + + this.stats.size = this.cache.size; + } + + /** + * Check if key exists and is valid + */ + has(key: string): boolean { + const entry = this.cache.get(key); + if (!entry) return false; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + this.stats.size = this.cache.size; + return false; + } + return true; + } + + /** + * Delete specific key + */ + delete(key: string): boolean { + const result = this.cache.delete(key); + this.stats.size = this.cache.size; + return result; + } + + /** + * Clear all cache entries + */ + clear(): void { + this.cache.clear(); + this.stats.size = 0; + } + + /** + * Clear expired entries + */ + prune(): number { + const now = Date.now(); + let pruned = 0; + + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expiresAt) { + this.cache.delete(key); + pruned++; + } + } + + this.stats.size = this.cache.size; + return pruned; + } + + /** + * Get cache statistics + */ + getStats(): CacheStats & { hitRate: number } { + const total = this.stats.hits + this.stats.misses; + return { + ...this.stats, + hitRate: total > 0 ? this.stats.hits / total : 0, + }; + } + + /** + * Get or fetch with caching + */ + async getOrFetch<T>( + key: string, + fetcher: () => Promise<T>, + ttlType: keyof CacheTTL = 'default', + ): Promise<T> { + const cached = this.get<T>(key); + if (cached !== null) { + return cached; + } + + const data = await fetcher(); + this.set(key, data, ttlType); + return data; + } + + /** + * Evict oldest entries + */ + private evictOldest(count: number): void { + const entries = Array.from(this.cache.entries()) + .sort((a, b) => a[1].createdAt - b[1].createdAt) + .slice(0, count); + + for (const [key] of entries) { + this.cache.delete(key); + } + + this.stats.size = this.cache.size; + } +} + +// Singleton instance for app-wide caching +export const marketCache = new CacheService(500); + +// Export class for testing or custom instances +export { CacheService }; +export type { CacheTTL, CacheStats }; diff --git a/apps/Cortensor-Price-Analyzer/src/lib/cortensorService.ts b/apps/Cortensor-Price-Analyzer/src/lib/cortensorService.ts index b0015b9..d384434 100644 --- a/apps/Cortensor-Price-Analyzer/src/lib/cortensorService.ts +++ b/apps/Cortensor-Price-Analyzer/src/lib/cortensorService.ts @@ -1,5 +1,6 @@ import axios, { type AxiosError } from 'axios'; import type { AIAnalysis, CatalystHighlight, MarketAnalysisContext, MarketNewsItem } from './marketTypes'; +import { marketCache } from './cacheService'; interface CortensorChoiceResponse { text?: string; @@ -12,14 +13,23 @@ interface CortensorApiResponse { const DEFAULT_CONFIDENCE = 'medium'; +interface RetryAttempt { + maxTokens: number; + timeout: number; + backoffMs: number; +} + export class CortensorService { private readonly apiKey: string; private readonly apiUrl: string; private readonly sessionId: number; + private readonly model: string; private readonly temperature: number; private readonly maxTokens: number; private readonly timeoutMs: number; private readonly retryTimeoutMs: number; + private readonly maxRetries: number; + private readonly enableCache: boolean; constructor() { this.apiKey = process.env.CORTENSOR_API_KEY ?? ''; @@ -27,30 +37,75 @@ export class CortensorService { throw new Error('CORTENSOR_API_KEY is required'); } - const baseUrl = process.env.CORTENSOR_BASE_URL ?? 'http://69.164.253.134:5010'; const explicitUrl = process.env.CORTENSOR_API_URL; - this.apiUrl = explicitUrl - ? explicitUrl.trim().replace(/\/$/, '') - : `${baseUrl.replace(/\/$/, '')}/api/v1/completions`; + const baseUrl = process.env.CORTENSOR_BASE_URL; + + if (explicitUrl) { + this.apiUrl = explicitUrl.trim().replace(/\/$/, ''); + } else if (baseUrl) { + this.apiUrl = `${baseUrl.replace(/\/$/, '')}/api/v1/completions`; + } else { + throw new Error('CORTENSOR_API_URL or CORTENSOR_BASE_URL is required'); + } + + const modelEnv = process.env.CORTENSOR_MODEL?.trim(); + this.model = modelEnv && modelEnv.length > 0 ? modelEnv : 'default'; const sessionEnv = process.env.CORTENSOR_SESSION_ID ?? process.env.CORTENSOR_SESSION ?? process.env.CORTENSOR_SESSIONID ?? '6'; - const parsedSession = Number.parseInt(sessionEnv, 10); - this.sessionId = Number.isFinite(parsedSession) ? parsedSession : 6; + const parsedSession = Number.parseInt(sessionEnv, 10); + this.sessionId = Number.isFinite(parsedSession) ? parsedSession : 6; this.temperature = Number.parseFloat(process.env.CORTENSOR_TEMPERATURE ?? '0.35'); this.maxTokens = Number.parseInt(process.env.CORTENSOR_MAX_TOKENS ?? '2800', 10); - const timeoutSeconds = Number.parseInt(process.env.CORTENSOR_TIMEOUT ?? '45', 10); - this.timeoutMs = Number.isFinite(timeoutSeconds) ? timeoutSeconds * 1000 : 45_000; - const retrySeconds = Number.parseInt(process.env.CORTENSOR_RETRY_TIMEOUT ?? '20', 10); - this.retryTimeoutMs = Number.isFinite(retrySeconds) ? retrySeconds * 1000 : 20_000; + + // Default timeout is 300s (overridable via env) + const timeoutSeconds = Number.parseInt(process.env.CORTENSOR_TIMEOUT ?? '300', 10); + this.timeoutMs = Number.isFinite(timeoutSeconds) ? timeoutSeconds * 1000 : 300_000; + + const retrySeconds = Number.parseInt(process.env.CORTENSOR_RETRY_TIMEOUT ?? '45', 10); + this.retryTimeoutMs = Number.isFinite(retrySeconds) ? retrySeconds * 1000 : 45_000; + + // Configurable retry count (default: 3) + this.maxRetries = Number.parseInt(process.env.CORTENSOR_MAX_RETRIES ?? '3', 10); + + // Enable/disable caching (default: enabled) + this.enableCache = process.env.CORTENSOR_CACHE_ENABLED !== 'false'; } async generatePriceAnalysis(context: MarketAnalysisContext): Promise<AIAnalysis> { + // Check cache first if enabled + if (this.enableCache) { + const cacheKey = marketCache.generateKey( + 'ai-analysis', + context.snapshot.ticker, + context.snapshot.assetType, + context.horizon, + ); + const cached = marketCache.get<AIAnalysis>(cacheKey); + if (cached) { + console.log(`[Cortensor] Cache hit for ${context.snapshot.ticker}`); + return cached; + } + } + const prompt = this.buildPrompt(context); try { const raw = await this.requestWithRetry(prompt); - return this.parseResponse(raw, context); + const analysis = this.parseResponse(raw, context); + + // Cache successful result + if (this.enableCache) { + const cacheKey = marketCache.generateKey( + 'ai-analysis', + context.snapshot.ticker, + context.snapshot.assetType, + context.horizon, + ); + marketCache.set(cacheKey, analysis, 'ai'); + } + + return analysis; } catch (error) { if (axios.isAxiosError(error)) { const descriptor = this.describeAxiosError(error); @@ -65,19 +120,31 @@ export class CortensorService { } private async requestWithRetry(prompt: string): Promise<string> { - const attempts = [ - { maxTokens: this.maxTokens, timeout: this.timeoutMs }, - { maxTokens: Math.max(512, Math.round(this.maxTokens * 0.6)), timeout: this.retryTimeoutMs }, - ]; + // Progressive retry strategy with exponential backoff + const attempts: RetryAttempt[] = [ + { maxTokens: this.maxTokens, timeout: this.timeoutMs, backoffMs: 0 }, + { maxTokens: Math.max(1800, Math.round(this.maxTokens * 0.75)), timeout: this.timeoutMs, backoffMs: 2000 }, + { maxTokens: Math.max(1200, Math.round(this.maxTokens * 0.5)), timeout: this.retryTimeoutMs, backoffMs: 4000 }, + ].slice(0, this.maxRetries); let lastError: unknown; for (let index = 0; index < attempts.length; index += 1) { const attempt = attempts[index]; + + // Apply exponential backoff before retry (not on first attempt) + if (index > 0 && attempt.backoffMs > 0) { + console.log(`[Cortensor] Waiting ${attempt.backoffMs}ms before retry ${index + 1}...`); + await this.delay(attempt.backoffMs); + } + try { + console.log(`[Cortensor] Attempt ${index + 1}/${attempts.length} (tokens: ${attempt.maxTokens}, timeout: ${attempt.timeout}ms)`); + const response = await axios.post<CortensorApiResponse>( this.apiUrl, { session_id: this.sessionId, + model: this.model, prompt, stream: false, temperature: this.temperature, @@ -89,23 +156,40 @@ export class CortensorService { 'Content-Type': 'application/json', }, timeout: attempt.timeout, + // Additional axios options for better reliability + validateStatus: (status) => status >= 200 && status < 500, }, ); + // Handle non-2xx responses that aren't network errors + if (response.status >= 400) { + const errorMessage = (response.data as { error?: string })?.error || `HTTP ${response.status}`; + throw new Error(errorMessage); + } + const raw = response.data?.choices?.[0]?.text ?? response.data?.text ?? ''; if (raw.trim().length === 0) { throw new Error('Empty Cortensor response'); } + + console.log(`[Cortensor] Success on attempt ${index + 1}`); return raw; } catch (error) { lastError = error; - if (!axios.isAxiosError(error) || !this.isRetriableError(error) || index === attempts.length - 1) { + + const isLastAttempt = index === attempts.length - 1; + const shouldRetry = !isLastAttempt && ( + !axios.isAxiosError(error) || this.isRetriableError(error) + ); + + if (!shouldRetry) { throw error; } - const descriptor = this.describeAxiosError(error); - console.warn(`Cortensor request attempt ${index + 1} failed (${descriptor}); retrying with lighter payload.`); - await this.delay(1_000 * (index + 1)); + const descriptor = axios.isAxiosError(error) + ? this.describeAxiosError(error) + : (error instanceof Error ? error.message : JSON.stringify(error)); + console.warn(`[Cortensor] Attempt ${index + 1} failed (${descriptor}); will retry with reduced payload.`); } } @@ -160,7 +244,7 @@ export class CortensorService { .join('\n') : 'No relevant headlines retrieved.'; - return `You are an institutional-grade market strategist. Produce a concise but comprehensive price analysis using the provided market context. + return `You are GPT-OSS-20B operating as an institutional-grade market strategist. Produce a concise but comprehensive price analysis using the provided market context. Return ONLY valid JSON that conforms to this TypeScript type (do not include markdown fences): { @@ -197,9 +281,9 @@ ${newsHighlights} GUIDELINES - Be decisive and avoid hedging language. -- Quantify each insight with the data provided. +- Quantify each insight with the data provided; keep bullets under 18 words. - Connect technical, fundamental, and news factors. -- Emphasize what matters over the next ${horizon}. +- Emphasize what matters over the next ${horizon}; ensure horizonNote mentions timing. - Output MUST be valid JSON.`; } @@ -387,11 +471,30 @@ GUIDELINES if (!error) { return false; } - const retriableStatuses = new Set([408, 429, 502, 503, 504]); + // Extended list of retriable status codes (server errors) + const retriableStatuses = new Set([408, 425, 429, 500, 502, 503, 504, 507, 599]); if (error.response?.status && retriableStatuses.has(error.response.status)) { return true; } - return error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT'; + // Network-level errors that are worth retrying + // NOTE: ECONNABORTED and ETIMEDOUT are NOT retried because for Cortensor, + // timeout usually means the task is still running on the router. + // Retrying would create duplicate tasks in the queue. + const retriableCodes = new Set([ + 'ECONNRESET', + 'ENOTFOUND', + 'ENETUNREACH', + 'ECONNREFUSED', + 'ERR_NETWORK', + ]); + + // Don't retry timeout errors - task is likely still running + if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + console.log(`[Cortensor] Timeout detected (${error.code}) - not retrying to avoid duplicate tasks`); + return false; + } + + return error.code ? retriableCodes.has(error.code) : false; } private delay(durationMs: number): Promise<void> { diff --git a/apps/Cortensor-Research-Summarizer/.gitignore b/apps/Cortensor-Research-Summarizer/.gitignore index 4578373..d01112e 100644 --- a/apps/Cortensor-Research-Summarizer/.gitignore +++ b/apps/Cortensor-Research-Summarizer/.gitignore @@ -59,6 +59,7 @@ yarn-error.log* .env*.local .env .env.* +.env.example # vercel .vercel @@ -86,3 +87,6 @@ setup-ssl.sh ecosystem.config.js nginx.conf nginx-dev.conf + +# Docs +README.md diff --git a/apps/Cortensor-Research-Summarizer/README.md b/apps/Cortensor-Research-Summarizer/README.md index e5bfb98..885d53e 100644 --- a/apps/Cortensor-Research-Summarizer/README.md +++ b/apps/Cortensor-Research-Summarizer/README.md @@ -43,11 +43,12 @@ A modern web application that intelligently summarizes articles from URLs using ### Installation ```bash npm install -cp .env.example .env.local -# Add your CORTENSOR_API_KEY to .env.local +# Create .env.local and add your required env vars npm run dev ``` +Note: after changing `.env.local`, restart the dev server so changes take effect. + Visit `http://localhost:3000` and start summarizing articles! 2. **Environment Configuration**: @@ -55,7 +56,8 @@ Create a `.env.local` file: ```env # Required: Cortensor Router Configuration CORTENSOR_API_KEY=your_cortensor_api_key_here -CORTENSOR_API_URL=https://<routerip>:5010 +# Base URL only (the app will call: $CORTENSOR_BASE_URL/api/v1/completions) +CORTENSOR_BASE_URL=https://<routerip>:5010 # Optional: Google Search API (for additional source search) GOOGLE_API_KEY=your_google_api_key_here diff --git a/apps/Cortensor-Research-Summarizer/package.json b/apps/Cortensor-Research-Summarizer/package.json index 5617475..679ce67 100644 --- a/apps/Cortensor-Research-Summarizer/package.json +++ b/apps/Cortensor-Research-Summarizer/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-slot": "^1.2.3", "@types/cheerio": "^0.22.35", "axios": "^1.12.2", + "better-sqlite3": "^12.5.0", "cheerio": "^1.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -29,6 +30,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/apps/Cortensor-Research-Summarizer/pnpm-lock.yaml b/apps/Cortensor-Research-Summarizer/pnpm-lock.yaml new file mode 100644 index 0000000..37409fc --- /dev/null +++ b/apps/Cortensor-Research-Summarizer/pnpm-lock.yaml @@ -0,0 +1,4868 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-progress': + specifier: ^1.1.7 + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-separator': + specifier: ^1.1.7 + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': + specifier: ^1.2.3 + version: 1.2.4(@types/react@19.2.7)(react@19.1.0) + '@types/cheerio': + specifier: ^0.22.35 + version: 0.22.35 + axios: + specifier: ^1.12.2 + version: 1.13.2 + better-sqlite3: + specifier: ^12.5.0 + version: 12.5.0 + cheerio: + specifier: ^1.1.0 + version: 1.1.2 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dommatrix: + specifier: ^0.1.1 + version: 0.1.1 + lucide-react: + specifier: ^0.545.0 + version: 0.545.0(react@19.1.0) + next: + specifier: ^16.0.7 + version: 16.1.1(@babel/core@7.28.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pdf-parse: + specifier: ^2.2.2 + version: 2.4.5 + react: + specifier: 19.1.0 + version: 19.1.0 + react-dom: + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) + tailwind-merge: + specifier: ^3.3.1 + version: 3.4.0 + devDependencies: + '@eslint/eslintrc': + specifier: ^3 + version: 3.3.3 + '@tailwindcss/postcss': + specifier: ^4 + version: 4.1.18 + '@types/better-sqlite3': + specifier: ^7.6.13 + version: 7.6.13 + '@types/node': + specifier: ^20 + version: 20.19.27 + '@types/react': + specifier: ^19 + version: 19.2.7 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.7) + eslint: + specifier: ^9 + version: 9.39.2(jiti@2.6.1) + eslint-config-next: + specifier: ^16.0.7 + version: 16.1.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.1.18 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ^5 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/canvas-android-arm64@0.1.80': + resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.80': + resolution: {integrity: sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.80': + resolution: {integrity: sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + resolution: {integrity: sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + resolution: {integrity: sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + resolution: {integrity: sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + resolution: {integrity: sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + resolution: {integrity: sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + resolution: {integrity: sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + resolution: {integrity: sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.80': + resolution: {integrity: sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@16.1.1': + resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==} + + '@next/eslint-plugin-next@16.1.1': + resolution: {integrity: sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw==} + + '@next/swc-darwin-arm64@16.1.1': + resolution: {integrity: sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.1.1': + resolution: {integrity: sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.1.1': + resolution: {integrity: sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.1.1': + resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.1.1': + resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.1.1': + resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.1.1': + resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.1.1': + resolution: {integrity: sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.8': + resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + + '@tailwindcss/postcss@4.1.18': + resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/better-sqlite3@7.6.13': + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + + '@types/cheerio@0.22.35': + resolution: {integrity: sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@20.19.27': + resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@typescript-eslint/eslint-plugin@8.50.1': + resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.50.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.50.1': + resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.50.1': + resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.50.1': + resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.50.1': + resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.50.1': + resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.50.1': + resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.50.1': + resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.50.1': + resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.50.1': + resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.0: + resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} + engines: {node: '>=4'} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.9.11: + resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} + hasBin: true + + better-sqlite3@12.5.0: + resolution: {integrity: sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001761: + resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.1.2: + resolution: {integrity: sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==} + engines: {node: '>=20.18.1'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + dommatrix@0.1.1: + resolution: {integrity: sha512-45CQT2bjnN9DXF07PXgG7hfQ2r8a6trrupNuTizRzfLHWWujdebAx8Xry0CB5uVBQlQY/m0TE/Whe5nrua+lsQ==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.18.4: + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@16.1.1: + resolution: {integrity: sha512-55nTpVWm3qeuxoQKLOjQVciKZJUphKrNM0fCcQHAIOGl6VFXgaqeMfv0aKJhs7QtcnlAPhNVqsqRfRjeKBPIUA==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + htmlparser2@10.0.0: + resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.545.0: + resolution: {integrity: sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next@16.1.1: + resolution: {integrity: sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-abi@3.85.0: + resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + engines: {node: '>=10'} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pdf-parse@2.4.5: + resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==} + engines: {node: '>=20.16.0 <21 || >=22.3.0'} + hasBin: true + + pdfjs-dist@5.4.296: + resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} + engines: {node: '>=20.16.0 || >=22.3.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + engines: {node: '>=0.10.0'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@3.4.0: + resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.50.1: + resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.19: + resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.2.1: + resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@2.6.1))': + dependencies: + eslint: 9.39.2(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.7.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/canvas-android-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.80': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.80': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.80': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.80': + optional: true + + '@napi-rs/canvas@0.1.80': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.80 + '@napi-rs/canvas-darwin-arm64': 0.1.80 + '@napi-rs/canvas-darwin-x64': 0.1.80 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.80 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.80 + '@napi-rs/canvas-linux-arm64-musl': 0.1.80 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-gnu': 0.1.80 + '@napi-rs/canvas-linux-x64-musl': 0.1.80 + '@napi-rs/canvas-win32-x64-msvc': 0.1.80 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@16.1.1': {} + + '@next/eslint-plugin-next@16.1.1': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.1.1': + optional: true + + '@next/swc-darwin-x64@16.1.1': + optional: true + + '@next/swc-linux-arm64-gnu@16.1.1': + optional: true + + '@next/swc-linux-arm64-musl@16.1.1': + optional: true + + '@next/swc-linux-x64-gnu@16.1.1': + optional: true + + '@next/swc-linux-x64-musl@16.1.1': + optional: true + + '@next/swc-win32-arm64-msvc@16.1.1': + optional: true + + '@next/swc-win32-x64-msvc@16.1.1': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.2.7 + + '@radix-ui/react-context@1.1.3(@types/react@19.2.7)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.2.7 + + '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.7)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + + '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.7)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.7)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.2.7 + + '@rtsao/scc@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.1.18': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.4 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/oxide-android-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide@4.1.18': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + + '@tailwindcss/postcss@4.1.18': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + postcss: 8.5.6 + tailwindcss: 4.1.18 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/better-sqlite3@7.6.13': + dependencies: + '@types/node': 20.19.27 + + '@types/cheerio@0.22.35': + dependencies: + '@types/node': 20.19.27 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.27': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.50.1 + eslint: 9.39.2(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.50.1 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.50.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) + '@typescript-eslint/types': 8.50.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.50.1': + dependencies: + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/visitor-keys': 8.50.1 + + '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.50.1': {} + + '@typescript-eslint/typescript-estree@8.50.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.50.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.9.3) + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/visitor-keys': 8.50.1 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.50.1': + dependencies: + '@typescript-eslint/types': 8.50.1 + eslint-visitor-keys: 4.2.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + asynckit@0.4.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.9.11: {} + + better-sqlite3@12.5.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + boolbase@1.0.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.11 + caniuse-lite: 1.0.30001761 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001761: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.1.2: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.0.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.16.0 + whatwg-mimetype: 4.0.0 + + chownr@1.1.4: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + dommatrix@0.1.1: {} + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.267: {} + + emoji-regex@9.2.2: {} + + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.18.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@4.5.0: {} + + entities@6.0.1: {} + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.19 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.2: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.1.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.1.1 + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) + globals: 16.4.0 + typescript-eslint: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + get-tsconfig: 4.13.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.0 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.2(jiti@2.6.1) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + eslint: 9.39.2(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.2.1 + zod-validation-error: 4.0.2(zod@4.2.1) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.2 + eslint: 9.39.2(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.2(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + expand-template@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fs-constants@1.0.0: {} + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-from-package@0.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + htmlparser2@10.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 6.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.3 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.19 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.545.0(react@19.1.0): + dependencies: + react: 19.1.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-response@3.1.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + napi-build-utils@2.0.0: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next@16.1.1(@babel/core@7.28.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@next/env': 16.1.1 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.9.11 + caniuse-lite: 1.0.30001761 + postcss: 8.4.31 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.1.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.1.1 + '@next/swc-darwin-x64': 16.1.1 + '@next/swc-linux-arm64-gnu': 16.1.1 + '@next/swc-linux-arm64-musl': 16.1.1 + '@next/swc-linux-x64-gnu': 16.1.1 + '@next/swc-linux-x64-musl': 16.1.1 + '@next/swc-win32-arm64-msvc': 16.1.1 + '@next/swc-win32-x64-msvc': 16.1.1 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-abi@3.85.0: + dependencies: + semver: 7.7.3 + + node-releases@2.0.27: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + pdf-parse@2.4.5: + dependencies: + '@napi-rs/canvas': 0.1.80 + pdfjs-dist: 5.4.296 + + pdfjs-dist@5.4.296: + optionalDependencies: + '@napi-rs/canvas': 0.1.80 + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.85.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@1.1.0: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + + react-is@16.13.1: {} + + react@19.1.0: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + scheduler@0.26.0: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + source-map-js@1.2.1: {} + + stable-hash@0.0.5: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-bom@3.0.0: {} + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.1.0): + dependencies: + client-only: 0.0.1 + react: 19.1.0 + optionalDependencies: + '@babel/core': 7.28.5 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@3.4.0: {} + + tailwindcss@4.1.18: {} + + tapable@2.3.0: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.1.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + undici@7.16.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.19 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.19: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.2.1): + dependencies: + zod: 4.2.1 + + zod@4.2.1: {} diff --git a/apps/Cortensor-Research-Summarizer/src/app/api/history/route.ts b/apps/Cortensor-Research-Summarizer/src/app/api/history/route.ts new file mode 100644 index 0000000..05f2bf1 --- /dev/null +++ b/apps/Cortensor-Research-Summarizer/src/app/api/history/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { clearHistory, deleteHistoryItem, listHistory, upsertHistoryItem } from '@/lib/historyDb'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function requireUserId(raw: string | null): string { + const userId = (raw ?? '').trim(); + if (!userId) throw new Error('userId is required'); + return userId; +} + +export async function GET(req: NextRequest) { + try { + const userId = requireUserId(req.nextUrl.searchParams.get('userId')); + const items = listHistory(userId); + return NextResponse.json({ items }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Bad request'; + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const userId = requireUserId(typeof body?.userId === 'string' ? body.userId : null); + + if (body?.action === 'clear') { + const deleted = clearHistory(userId); + return NextResponse.json({ success: true, deleted }); + } + + const item = body?.item; + if (!item || typeof item.url !== 'string' || typeof item.title !== 'string' || typeof item.summary !== 'string') { + return NextResponse.json({ error: 'item is required' }, { status: 400 }); + } + + const saved = upsertHistoryItem(userId, { + timestamp: typeof item.timestamp === 'number' ? item.timestamp : Date.now(), + url: item.url, + title: item.title, + author: typeof item.author === 'string' ? item.author : undefined, + publishDate: typeof item.publishDate === 'string' ? item.publishDate : undefined, + summary: item.summary, + keyPoints: Array.isArray(item.keyPoints) ? item.keyPoints : [], + wordCount: typeof item.wordCount === 'number' ? item.wordCount : 0, + wasEnriched: !!item.wasEnriched, + }); + + return NextResponse.json({ success: true, item: saved }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Bad request'; + return NextResponse.json({ error: message }, { status: 400 }); + } +} + +export async function DELETE(req: NextRequest) { + try { + const body = await req.json(); + const userId = requireUserId(typeof body?.userId === 'string' ? body.userId : null); + const id = typeof body?.id === 'string' ? body.id : ''; + if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 }); + + const ok = deleteHistoryItem(userId, id); + return NextResponse.json({ success: ok }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Bad request'; + return NextResponse.json({ error: message }, { status: 400 }); + } +} diff --git a/apps/Cortensor-Research-Summarizer/src/app/api/news/route.ts b/apps/Cortensor-Research-Summarizer/src/app/api/news/route.ts index b56fd00..0cfe48b 100644 --- a/apps/Cortensor-Research-Summarizer/src/app/api/news/route.ts +++ b/apps/Cortensor-Research-Summarizer/src/app/api/news/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server'; import { fetchNews } from '@/lib/newsService'; +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + type Category = 'general' | 'technology' | 'science' | 'random'; export async function GET(request: Request) { diff --git a/apps/Cortensor-Research-Summarizer/src/app/api/summarize/route.ts b/apps/Cortensor-Research-Summarizer/src/app/api/summarize/route.ts index 76521c9..90742a0 100644 --- a/apps/Cortensor-Research-Summarizer/src/app/api/summarize/route.ts +++ b/apps/Cortensor-Research-Summarizer/src/app/api/summarize/route.ts @@ -2,12 +2,16 @@ import { NextRequest, NextResponse } from 'next/server'; import { URLFetcher } from '@/lib/urlFetcher'; import { CortensorService } from '@/lib/cortensorService'; import { SearchService } from '@/lib/searchService'; +import { debugLog } from '@/lib/env'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; // Function to filter DeepSeek thinking process and extract only the actual response function filterDeepSeekOutput(text: string): string { if (!text) return ''; - console.log('🧠 Raw DeepSeek output length:', text.length); + debugLog('🧠 Raw DeepSeek output length:', text.length); // Remove <think>...</think> blocks let cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, ''); @@ -16,9 +20,9 @@ function filterDeepSeekOutput(text: string): string { const thinkEndIndex = text.toLowerCase().lastIndexOf('</think>'); if (thinkEndIndex !== -1) { cleaned = text.substring(thinkEndIndex + 8).trim(); // 8 = length of '</think>' - console.log('🎯 Found </think> tag, extracted content after it'); + debugLog('🎯 Found </think> tag, extracted content after it'); } else { - console.log('ℹ️ No </think> tag found, using cleaned text'); + debugLog('ℹ️ No </think> tag found, using cleaned text'); } // Clean up any remaining artifacts @@ -29,7 +33,7 @@ function filterDeepSeekOutput(text: string): string { .replace(/▁/g, ' ') .trim(); - console.log('✅ Filtered output length:', cleaned.length); + debugLog('✅ Filtered output length:', cleaned.length); return cleaned; } @@ -48,7 +52,7 @@ function processSummaryResponse(summaryData: { summary?: string; keyPoints?: str if (keyInsightsMatch) { const keyInsightsText = keyInsightsMatch[1]; - console.log('🔍 Found key insights text:', keyInsightsText.substring(0, 200) + '...'); + debugLog('🔍 Found key insights text:', keyInsightsText.substring(0, 200) + '...'); // Split by bullet points first - look for • followed by text const bulletSplit = keyInsightsText.split(/\s*•\s*/).filter(part => part.trim().length > 10); @@ -67,7 +71,7 @@ function processSummaryResponse(summaryData: { summary?: string; keyPoints?: str .trim(); }).filter(point => point.length > 15); - console.log('✅ Extracted', keyPoints.length, 'key points from bullet splits'); + debugLog('✅ Extracted', keyPoints.length, 'key points from bullet splits'); } else { // Fallback: try other bullet patterns const bulletPoints = keyInsightsText.match(/(?:^|\n)[\s]*(?:[-•*]|\d+\.)\s*(.+)/gm); @@ -76,13 +80,13 @@ function processSummaryResponse(summaryData: { summary?: string; keyPoints?: str keyPoints = bulletPoints.map(point => point.replace(/^[\s]*(?:[-•*]|\d+\.)\s*/, '').trim() ).filter(point => point.length > 10); - console.log('✅ Extracted', keyPoints.length, 'key points from regex pattern'); + debugLog('✅ Extracted', keyPoints.length, 'key points from regex pattern'); } else { // Last resort: split by sentences that seem like key points const sentences = keyInsightsText.split(/(?<=[.!?])\s+/).filter(s => s.trim().length > 20); if (sentences.length > 1) { keyPoints = sentences.map(s => s.trim()).filter(s => s.length > 0); - console.log('📝 Split into', keyPoints.length, 'sentences as key points'); + debugLog('📝 Split into', keyPoints.length, 'sentences as key points'); } } } @@ -106,7 +110,7 @@ function processSummaryResponse(summaryData: { summary?: string; keyPoints?: str wordCount = summary.split(/\s+/).filter(word => word.length > 0).length; - console.log('📊 Processing result: Summary length:', summary.length, 'Key points:', keyPoints.length); + debugLog('📊 Processing result: Summary length:', summary.length, 'Key points:', keyPoints.length); return { summary, keyPoints, wordCount, wasEnriched }; } @@ -128,19 +132,26 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'URL is required' }, { status: 400 }); } - console.log('Processing URL:', url); + debugLog('Processing URL:', url); const clientReference = buildClientReference(userId); - console.log('Using client reference:', clientReference); + debugLog('Using client reference:', clientReference); const urlFetcher = new URLFetcher(); - const article = await urlFetcher.fetchArticle(url); + let article; + try { + article = await urlFetcher.fetchArticle(url); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch article'; + console.error('Article fetch error:', message); + return NextResponse.json({ error: message }, { status: 422 }); + } if (!article || !article.content) { return NextResponse.json({ error: 'Failed to extract content from URL' }, { status: 400 }); } - console.log('Article fetched:', { + debugLog('Article fetched:', { title: article.title, contentLength: article.content.length, author: article.author diff --git a/apps/Cortensor-Research-Summarizer/src/app/api/summarize/stream/route.ts b/apps/Cortensor-Research-Summarizer/src/app/api/summarize/stream/route.ts new file mode 100644 index 0000000..3084012 --- /dev/null +++ b/apps/Cortensor-Research-Summarizer/src/app/api/summarize/stream/route.ts @@ -0,0 +1,263 @@ +import { NextRequest } from 'next/server'; +import { URLFetcher } from '@/lib/urlFetcher'; +import { CortensorService } from '@/lib/cortensorService'; +import { SearchService } from '@/lib/searchService'; +import { debugLog } from '@/lib/env'; +import { upsertHistoryItem } from '@/lib/historyDb'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +function filterDeepSeekOutput(text: string): string { + if (!text) return ''; + let cleaned = text.replace(/<think>[\s\S]*?<\/think>/gi, ''); + const thinkEndIndex = text.toLowerCase().lastIndexOf('</think>'); + if (thinkEndIndex !== -1) { + cleaned = text.substring(thinkEndIndex + 8).trim(); + } + cleaned = cleaned + .replace(/|end▁of▁sentence|/g, '') + .replace(/\<\|end▁of▁sentence\|\>/g, '') + .replace(/▁+/g, ' ') + .replace(/▁/g, ' ') + .trim(); + return cleaned; +} + +function processSummaryResponse(summaryData: { summary?: string; keyPoints?: string[]; wasEnriched?: boolean }) { + let summary = summaryData.summary || ''; + let keyPoints: string[] = []; + + summary = filterDeepSeekOutput(summary); + + const keyInsightsMatch = summary.match(/\*\*KEY INSIGHTS?\*\*:?(\s*)([\s\S]*?)(?=\n\n|\*\*ADDITIONAL|$)/i) || + summary.match(/KEY INSIGHTS?\s*:?(\s*)([\s\S]*?)(?=\n\n|\*\*ADDITIONAL|$)/i); + + if (keyInsightsMatch) { + const keyInsightsText = keyInsightsMatch[2]; + const bulletSplit = keyInsightsText.split(/\s*•\s*/).filter(part => part.trim().length > 10); + + if (bulletSplit.length > 1) { + if (bulletSplit[0].trim().length < 50 || !bulletSplit[0].includes('.')) { + bulletSplit.shift(); + } + + keyPoints = bulletSplit.map(point => point.trim() + .replace(/^\s*[\-\*•]\s*/, '') + .replace(/\s+/g, ' ') + .trim()).filter(point => point.length > 15); + } else { + const bulletPoints = keyInsightsText.match(/(?:^|\n)[\s]*(?:[-•*]|\d+\.)\s*(.+)/gm); + if (bulletPoints && bulletPoints.length > 0) { + keyPoints = bulletPoints.map(point => point.replace(/^[\s]*(?:[-•*]|\d+\.)\s*/, '').trim()).filter(point => point.length > 10); + } else { + const sentences = keyInsightsText.split(/(?<=[.!?])\s+/).filter(s => s.trim().length > 20); + if (sentences.length > 1) { + keyPoints = sentences.map(s => s.trim()).filter(Boolean); + } + } + } + + summary = summary.replace(/\*\*KEY INSIGHTS?\*\*:?[\s\S]*?(?=\n\n|\*\*ADDITIONAL|$)/i, '').trim(); + summary = summary.replace(/KEY INSIGHTS?\s*:?[\s\S]*?(?=\n\n|\*\*ADDITIONAL|$)/i, '').trim(); + } + + if (keyPoints.length === 0 && summaryData.keyPoints) { + keyPoints = Array.isArray(summaryData.keyPoints) ? summaryData.keyPoints : []; + } + + summary = summary + .replace(/\*\*KEY INSIGHTS?\*\*/gi, '') + .replace(/^KEY INSIGHTS?:?\s*/gmi, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + const wordCount = summary.split(/\s+/).filter(Boolean).length; + + return { summary, keyPoints, wordCount, wasEnriched: summaryData.wasEnriched || false }; +} + +function buildClientReference(rawUserId: unknown): string { + const raw = typeof rawUserId === 'string' ? rawUserId.trim() : ''; + const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, ''); + const baseId = cleaned.length > 0 ? cleaned : `session-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return baseId.startsWith('user-summarizer-') ? baseId : `user-summarizer-${baseId}`; +} + +function nowMs(): number { + // perf_hooks isn't necessary; Date.now is fine for UX durations + return Date.now(); +} + +export async function POST(request: NextRequest) { + const encoder = new TextEncoder(); + + const stream = new ReadableStream<Uint8Array>({ + async start(controller) { + const send = (event: string, data: any) => { + controller.enqueue(encoder.encode(`event: ${event}\n`)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)); + }; + + const stepOrder = ['validate', 'extract', 'prepare', 'summarize', 'insights', 'quality', 'enrich', 'finalize'] as const; + const stepIndex = (id: string) => Math.max(0, stepOrder.indexOf(id as any)); + const progressFor = (id: string) => Math.round(((stepIndex(id) + 1) / stepOrder.length) * 100); + + const stepStart: Record<string, number> = {}; + const beginStep = (id: string) => { + stepStart[id] = nowMs(); + send('step', { id, status: 'active', progress: progressFor(id) }); + }; + const completeStep = (id: string) => { + const durationMs = Math.max(0, nowMs() - (stepStart[id] ?? nowMs())); + send('step', { id, status: 'completed', duration: `${(durationMs / 1000).toFixed(1)}s`, progress: progressFor(id) }); + }; + const errorStep = (id: string, message: string) => { + send('step', { id, status: 'error', progress: progressFor(id) }); + send('error', { message }); + }; + + try { + const { url, userId } = await request.json(); + + beginStep('validate'); + if (!url || typeof url !== 'string') { + errorStep('validate', 'URL is required'); + controller.close(); + return; + } + // validate URL format + try { + new URL(url); + } catch { + errorStep('validate', 'Invalid URL'); + controller.close(); + return; + } + completeStep('validate'); + + beginStep('extract'); + const urlFetcher = new URLFetcher(); + let article; + try { + article = await urlFetcher.fetchArticle(url); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch article'; + errorStep('extract', message); + controller.close(); + return; + } + if (!article || !article.content) { + errorStep('extract', 'Failed to extract content from URL'); + controller.close(); + return; + } + completeStep('extract'); + + beginStep('prepare'); + // placeholder for future: chunking / compression decisions are inside CortensorService + completeStep('prepare'); + + beginStep('summarize'); + const clientReference = buildClientReference(userId); + + const searchService = new SearchService(); + // search can take time too; we keep it under summarize step for now + const searchResults = await searchService.searchAdditionalSources( + article.title || 'research topic', + [], + article.url + ); + + const cortensorService = new CortensorService(); + let summaryResult; + try { + summaryResult = await cortensorService.generateSummary(article, clientReference); + if (summaryResult.needsEnrichment && searchResults.length > 0) { + summaryResult = await cortensorService.enrichSummary(summaryResult, searchResults, `${clientReference}-enrich`); + } + } catch (err) { + debugLog('Cortensor API Error:', err); + errorStep('summarize', 'Failed to generate summary with AI service'); + controller.close(); + return; + } + completeStep('summarize'); + + beginStep('insights'); + const processedSummary = processSummaryResponse(summaryResult); + completeStep('insights'); + + beginStep('quality'); + // future: quality scoring; for now mark completed + completeStep('quality'); + + beginStep('enrich'); + if (summaryResult.needsEnrichment || summaryResult.wasEnriched) { + // already applied during summarize; mark as completed + completeStep('enrich'); + } else { + send('step', { id: 'enrich', status: 'completed', duration: 'Skipped', progress: progressFor('enrich') }); + } + + beginStep('finalize'); + + const resultData = { + article: { + title: article.title, + author: article.author, + publishDate: article.publishDate, + url: article.url, + }, + summary: processedSummary.summary, + keyPoints: processedSummary.keyPoints, + wordCount: processedSummary.wordCount, + wasEnriched: processedSummary.wasEnriched, + needsEnrichment: summaryResult.needsEnrichment, + contentTruncated: summaryResult.sourceTruncated ?? false, + originalContentLength: summaryResult.originalContentLength ?? article.content.length, + submittedContentLength: summaryResult.submittedContentLength ?? article.content.length, + compressionMethod: summaryResult.compressionMethod ?? 'pass-through', + }; + + // Persist to DB (best-effort; tied to userId if present) + const userIdString = typeof userId === 'string' ? userId.trim() : ''; + if (userIdString) { + try { + upsertHistoryItem(userIdString, { + timestamp: Date.now(), + url: resultData.article.url, + title: resultData.article.title, + author: resultData.article.author ?? undefined, + publishDate: resultData.article.publishDate ?? undefined, + summary: resultData.summary, + keyPoints: resultData.keyPoints, + wordCount: resultData.wordCount, + wasEnriched: !!resultData.wasEnriched, + }); + } catch (dbErr) { + debugLog('History DB write failed:', dbErr); + } + } + + completeStep('finalize'); + + send('result', { success: true, data: resultData }); + send('done', { ok: true }); + controller.close(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Internal server error'; + send('error', { message }); + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + }, + }); +} diff --git a/apps/Cortensor-Research-Summarizer/src/components/HistoryPanel.tsx b/apps/Cortensor-Research-Summarizer/src/components/HistoryPanel.tsx index e8ec3f0..afc7ff5 100644 --- a/apps/Cortensor-Research-Summarizer/src/components/HistoryPanel.tsx +++ b/apps/Cortensor-Research-Summarizer/src/components/HistoryPanel.tsx @@ -22,10 +22,11 @@ import { interface HistoryPanelProps { onLoadHistoryItem: (item: HistoryItem) => void; onHistoryChange?: () => void; // Callback when history changes + userId?: string | null; className?: string; } -export function HistoryPanel({ onLoadHistoryItem, onHistoryChange, className }: HistoryPanelProps) { +export function HistoryPanel({ onLoadHistoryItem, onHistoryChange, userId, className }: HistoryPanelProps) { const [history, setHistory] = useState<HistoryItem[]>([]); const [searchQuery, setSearchQuery] = useState(''); const [filteredHistory, setFilteredHistory] = useState<HistoryItem[]>([]); @@ -33,20 +34,29 @@ export function HistoryPanel({ onLoadHistoryItem, onHistoryChange, className }: useEffect(() => { loadHistory(); - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId]); useEffect(() => { if (searchQuery.trim()) { - setFilteredHistory(HistoryService.searchHistory(searchQuery.trim())); + const q = searchQuery.trim().toLowerCase(); + setFilteredHistory( + history.filter(item => + item.title.toLowerCase().includes(q) || + item.url.toLowerCase().includes(q) || + item.summary.toLowerCase().includes(q) || + (item.author && item.author.toLowerCase().includes(q)) + ) + ); } else { setFilteredHistory(history); } }, [searchQuery, history]); - const loadHistory = () => { + const loadHistory = async () => { setIsLoading(true); try { - const items = HistoryService.getHistory(); + const items = await HistoryService.getHistory(userId); setHistory(items); setFilteredHistory(items); } catch (error) { @@ -56,17 +66,17 @@ export function HistoryPanel({ onLoadHistoryItem, onHistoryChange, className }: } }; - const handleDeleteItem = (id: string, event: React.MouseEvent) => { + const handleDeleteItem = async (id: string, event: React.MouseEvent) => { event.stopPropagation(); - HistoryService.deleteHistoryItem(id); - loadHistory(); + await HistoryService.deleteHistoryItem(userId, id); + await loadHistory(); onHistoryChange?.(); // Notify parent of change }; - const handleClearHistory = () => { + const handleClearHistory = async () => { if (window.confirm('Are you sure you want to clear all history? This action cannot be undone.')) { - HistoryService.clearHistory(); - loadHistory(); + await HistoryService.clearHistory(userId); + await loadHistory(); onHistoryChange?.(); // Notify parent of change } }; diff --git a/apps/Cortensor-Research-Summarizer/src/components/NewsPanel.tsx b/apps/Cortensor-Research-Summarizer/src/components/NewsPanel.tsx index efd41f1..23df002 100644 --- a/apps/Cortensor-Research-Summarizer/src/components/NewsPanel.tsx +++ b/apps/Cortensor-Research-Summarizer/src/components/NewsPanel.tsx @@ -49,7 +49,6 @@ export function NewsPanel({ className }: { className?: string }) { useEffect(() => { loadNews(activeCategory); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeCategory]); return ( diff --git a/apps/Cortensor-Research-Summarizer/src/components/SummarizerForm.tsx b/apps/Cortensor-Research-Summarizer/src/components/SummarizerForm.tsx index 933d0e2..26020ff 100644 --- a/apps/Cortensor-Research-Summarizer/src/components/SummarizerForm.tsx +++ b/apps/Cortensor-Research-Summarizer/src/components/SummarizerForm.tsx @@ -105,23 +105,48 @@ export default function SummarizerForm() { const [historyCount, setHistoryCount] = useState(0); // Handle history changes - const handleHistoryChange = () => { - setHistoryCount(HistoryService.getHistory().length); + const handleHistoryChange = async (overrideUserId?: string | null) => { + try { + const uid = overrideUserId ?? userId; + const items = await HistoryService.getHistory(uid); + setHistoryCount(items.length); + } catch (err) { + console.warn('Failed to refresh history count:', err); + } }; - // Load history count on component mount + // Initialize user id on mount useEffect(() => { - handleHistoryChange(); const initializedId = getOrCreateUserId(); if (initializedId) { setUserId(initializedId); } }, []); + // Keep history count in sync with current user + useEffect(() => { + let cancelled = false; + (async () => { + try { + const items = await HistoryService.getHistory(userId); + if (!cancelled) { + setHistoryCount(items.length); + } + } catch (err) { + console.warn('Failed to refresh history count:', err); + } + })(); + + return () => { + cancelled = true; + }; + }, [userId]); + // Save result to history using HistoryService - const saveToHistory = (newResult: SummaryResult) => { + const saveToHistory = async (newResult: SummaryResult, overrideUserId?: string) => { try { - HistoryService.saveToHistory({ + const uid = overrideUserId ?? userId; + await HistoryService.saveToHistory(uid, { url: newResult.article.url, title: newResult.article.title, author: newResult.article.author, @@ -133,8 +158,7 @@ export default function SummarizerForm() { }); // Update history count - setHistoryCount(HistoryService.getHistory().length); - handleHistoryChange(); + await handleHistoryChange(uid); } catch (error) { console.error('Error saving to history:', error); } @@ -245,19 +269,6 @@ export default function SummarizerForm() { setProgress(0); }; - const simulateProgress = () => { - const totalSteps = loadingSteps.length; - const currentStep = 0; - - const progressInterval = setInterval(() => { - const baseProgress = (currentStep / totalSteps) * 100; - const stepProgress = Math.min(baseProgress + Math.random() * 10, (currentStep + 1) / totalSteps * 100); - setProgress(stepProgress); - }, 200); - - return progressInterval; - }; - // Function to ensure proper paragraph formatting on client side const formatSummaryForDisplay = (summary: string): string => { if (!summary) return ''; @@ -337,32 +348,10 @@ export default function SummarizerForm() { setResult(null); resetSteps(); - const progressInterval = simulateProgress(); - try { - // Step 1: Validation - updateStepStatus('validate', 'active'); - await new Promise(resolve => setTimeout(resolve, 800)); - updateStepStatus('validate', 'completed', '0.8s'); - - // Step 2: Extraction - updateStepStatus('extract', 'active'); - await new Promise(resolve => setTimeout(resolve, 1200)); - updateStepStatus('extract', 'completed', '1.2s'); - - // Step 3: Content Preparation - updateStepStatus('prepare', 'active'); - await new Promise(resolve => setTimeout(resolve, 900)); - updateStepStatus('prepare', 'completed', '0.9s'); - - // Step 4: AI Summarization (Long Process) - updateStepStatus('summarize', 'active'); - - const response = await fetch('/api/summarize', { + const response = await fetch('/api/summarize/stream', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url, userId: activeUserId }), }); @@ -370,44 +359,80 @@ export default function SummarizerForm() { throw new Error(`Analysis failed: ${response.statusText}`); } - const response_data = await response.json(); - const data = response_data.data; // Extract the actual data from the API response - - updateStepStatus('summarize', 'completed', '45.2s'); - - // Step 5: Key Insights Extraction - updateStepStatus('insights', 'active'); - await new Promise(resolve => setTimeout(resolve, 800)); - updateStepStatus('insights', 'completed', '0.8s'); - - // Step 6: Quality Assessment - updateStepStatus('quality', 'active'); - await new Promise(resolve => setTimeout(resolve, 600)); - updateStepStatus('quality', 'completed', '0.6s'); - - // Step 7: Enhancement (if needed) - if (data.needsEnrichment || response_data.data.wasEnriched) { - updateStepStatus('enrich', 'active'); - await new Promise(resolve => setTimeout(resolve, 2500)); - updateStepStatus('enrich', 'completed', '12.5s'); - } else { - updateStepStatus('enrich', 'completed', 'Skipped'); + if (!response.body) { + throw new Error('Streaming not supported by this browser'); } - // Step 8: Finalize - updateStepStatus('finalize', 'active'); - await new Promise(resolve => setTimeout(resolve, 400)); - updateStepStatus('finalize', 'completed', '0.4s'); - - // Data is already processed by the API, but let's ensure proper formatting - const formattedData = { - ...data, - summary: formatSummaryForDisplay(data.summary || '') + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + const handleChunk = async (chunk: string) => { + const lines = chunk.split('\n'); + let eventName = 'message'; + let dataStr = ''; + for (const line of lines) { + if (line.startsWith('event:')) { + eventName = line.slice('event:'.length).trim(); + } else if (line.startsWith('data:')) { + dataStr += line.slice('data:'.length).trim(); + } + } + + if (!dataStr) return; + + let payload: any; + try { + payload = JSON.parse(dataStr); + } catch { + return; + } + + if (eventName === 'step') { + const stepId = typeof payload?.id === 'string' ? payload.id : ''; + const status = payload?.status as LoadingStep['status']; + const duration = typeof payload?.duration === 'string' ? payload.duration : undefined; + const progressValue = typeof payload?.progress === 'number' ? payload.progress : undefined; + + if (stepId && status) { + updateStepStatus(stepId, status, duration); + } + if (typeof progressValue === 'number') { + setProgress(progressValue); + } + } + + if (eventName === 'error') { + const message = typeof payload?.message === 'string' ? payload.message : 'An unexpected error occurred during analysis'; + const currentActiveStep = loadingSteps.find(step => step.status === 'active'); + if (currentActiveStep) updateStepStatus(currentActiveStep.id, 'error'); + setError(message); + } + + if (eventName === 'result') { + const data = payload?.data; + if (data) { + const formattedData = { + ...data, + summary: formatSummaryForDisplay(data.summary || '') + }; + setResult(formattedData); + await saveToHistory(formattedData, activeUserId); + setProgress(100); + } + } }; - - setResult(formattedData); - saveToHistory(formattedData); // Save to local history - setProgress(100); + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; + for (const part of parts) { + await handleChunk(part); + } + } } catch (err) { const currentActiveStep = loadingSteps.find(step => step.status === 'active'); @@ -416,7 +441,6 @@ export default function SummarizerForm() { } setError(err instanceof Error ? err.message : 'An unexpected error occurred during analysis'); } finally { - clearInterval(progressInterval); setIsLoading(false); } }; @@ -864,7 +888,8 @@ ${result.keyPoints.map(point => `- ${point}`).join('\n')} {showHistoryPanel && ( <HistoryPanel onLoadHistoryItem={loadFromHistory} - onHistoryChange={handleHistoryChange} + onHistoryChange={() => { void handleHistoryChange(); }} + userId={userId} className="shadow-lg" /> )} diff --git a/apps/Cortensor-Research-Summarizer/src/lib/cortensorService.ts b/apps/Cortensor-Research-Summarizer/src/lib/cortensorService.ts index 72604d6..89a9afa 100644 --- a/apps/Cortensor-Research-Summarizer/src/lib/cortensorService.ts +++ b/apps/Cortensor-Research-Summarizer/src/lib/cortensorService.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import type { ArticleContent } from './urlFetcher'; +import { env, debugLog } from './env'; export interface SummaryResult { summary: string; @@ -41,34 +42,61 @@ export class CortensorService { private readonly maxContentCharactersCap: number | null; constructor() { - this.apiKey = process.env.CORTENSOR_API_KEY || ''; - - // Use the standard API endpoint as per documentation - const baseUrl = process.env.CORTENSOR_BASE_URL || 'http://69.164.253.134:5010'; - this.apiUrl = `${baseUrl}/api/v1/completions`; - - // Get session ID from environment variable - this.sessionId = parseInt(process.env.CORTENSOR_SESSION || '6'); - this.promptType = parseInt(process.env.CORTENSOR_PROMPT_TYPE || '1'); - this.timeout = parseInt(process.env.CORTENSOR_TIMEOUT || '300'); - this.precommitTimeout = parseInt(process.env.CORTENSOR_PRECOMMIT_TIMEOUT || '90'); - this.maxTokens = parseInt(process.env.CORTENSOR_MAX_TOKENS || '6000'); - this.temperature = parseFloat(process.env.CORTENSOR_TEMPERATURE || '0.3'); - this.topP = parseFloat(process.env.CORTENSOR_TOP_P || '0.9'); - this.topK = parseInt(process.env.CORTENSOR_TOP_K || '40'); - this.presencePenalty = parseFloat(process.env.CORTENSOR_PRESENCE_PENALTY || '0'); - this.frequencyPenalty = parseFloat(process.env.CORTENSOR_FREQUENCY_PENALTY || '0'); - this.stream = process.env.CORTENSOR_STREAM === 'true'; - this.sessionLimitsLoaded = false; - this.fallbackMaxContextTokens = parseInt(process.env.CORTENSOR_FALLBACK_MAX_CONTEXT_TOKENS || '5000', 10); - this.promptTokenReserve = parseInt(process.env.CORTENSOR_PROMPT_TOKEN_RESERVE || '1200', 10); - this.averageCharsPerToken = parseFloat(process.env.CORTENSOR_AVG_CHARS_PER_TOKEN || '3'); - const configuredMaxChars = parseInt(process.env.CORTENSOR_MAX_CONTENT_CHARS || '9500', 10); - this.maxContentCharactersCap = Number.isFinite(configuredMaxChars) && configuredMaxChars > 0 ? configuredMaxChars : null; - - if (!this.apiKey) { - throw new Error('CORTENSOR_API_KEY is required'); - } + this.apiKey = env.CORTENSOR_API_KEY; + + this.apiUrl = `${env.CORTENSOR_BASE_URL}/api/v1/completions`; + debugLog('🔗 Cortensor API URL:', this.apiUrl); + + this.sessionId = env.CORTENSOR_SESSION; + this.promptType = env.CORTENSOR_PROMPT_TYPE; + this.timeout = env.CORTENSOR_TIMEOUT; + this.precommitTimeout = env.CORTENSOR_PRECOMMIT_TIMEOUT; + this.maxTokens = env.CORTENSOR_MAX_TOKENS; + this.temperature = env.CORTENSOR_TEMPERATURE; + this.topP = env.CORTENSOR_TOP_P; + this.topK = env.CORTENSOR_TOP_K; + this.presencePenalty = env.CORTENSOR_PRESENCE_PENALTY; + this.frequencyPenalty = env.CORTENSOR_FREQUENCY_PENALTY; + this.stream = env.CORTENSOR_STREAM; + + this.sessionLimitsLoaded = false; + this.fallbackMaxContextTokens = env.CORTENSOR_FALLBACK_MAX_CONTEXT_TOKENS; + this.promptTokenReserve = env.CORTENSOR_PROMPT_TOKEN_RESERVE; + this.averageCharsPerToken = env.CORTENSOR_AVG_CHARS_PER_TOKEN; + const configuredMaxChars = env.CORTENSOR_MAX_CONTENT_CHARS; + this.maxContentCharactersCap = Number.isFinite(configuredMaxChars) && configuredMaxChars > 0 ? configuredMaxChars : null; + } + + private buildCompletionRequestPayload(prompt: string, clientReference: string) { + return { + session_id: this.sessionId, + prompt_type: this.promptType, + prompt, + stream: this.stream, + timeout: this.timeout, + precommit_timeout: this.precommitTimeout, + max_tokens: this.maxTokens, + temperature: this.temperature, + top_p: this.topP, + top_k: this.topK, + presence_penalty: this.presencePenalty, + frequency_penalty: this.frequencyPenalty, + client_reference: clientReference + }; + } + + private buildAxiosConfig() { + const timeoutMs = Number.isFinite(this.timeout) && this.timeout > 0 + ? (this.timeout + Math.max(this.precommitTimeout, 0) + 60) * 1000 + : 360000; + + return { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + }, + timeout: timeoutMs + }; } async generateSummary(article: ArticleContent, clientReference?: string): Promise<SummaryResult> { @@ -79,23 +107,26 @@ export class CortensorService { const promptArticle: ArticleContent = { ...article, content: truncation.text }; const prompt = this.createSummaryPrompt(promptArticle, truncation.truncated); const effectiveClientReference = clientReference ?? this.generateFallbackClientReference('summary'); - const requestPayload = { - session_id: this.sessionId, // Use session_id from environment variable - prompt, - stream: this.stream, + const requestPayload = this.buildCompletionRequestPayload(prompt, effectiveClientReference); + + debugLog('🧩 Cortensor request params:', { + session_id: this.sessionId, + prompt_type: this.promptType, + max_tokens: this.maxTokens, timeout: this.timeout, - client_reference: effectiveClientReference - }; + precommit_timeout: this.precommitTimeout, + temperature: this.temperature, + top_p: this.topP, + top_k: this.topK, + presence_penalty: this.presencePenalty, + frequency_penalty: this.frequencyPenalty, + stream: this.stream + }); const response = await axios.post( this.apiUrl, requestPayload, - { - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json' - } - } + this.buildAxiosConfig() ); const summaryText = response.data.choices?.[0]?.text || response.data.text || ''; @@ -127,23 +158,12 @@ export class CortensorService { try { const prompt = this.createEnrichmentPrompt(originalSummary, additionalSources); const effectiveClientReference = clientReference ?? this.generateFallbackClientReference('enrich'); - const requestPayload = { - session_id: this.sessionId, // Use session_id from environment variable - prompt, - stream: this.stream, - timeout: this.timeout, - client_reference: effectiveClientReference - }; + const requestPayload = this.buildCompletionRequestPayload(prompt, effectiveClientReference); const response = await axios.post( this.apiUrl, requestPayload, - { - headers: { - 'Authorization': `Bearer ${this.apiKey}`, - 'Content-Type': 'application/json' - } - } + this.buildAxiosConfig() ); const enrichedText = response.data.choices?.[0]?.text || response.data.text || ''; @@ -229,12 +249,16 @@ Begin your professional analysis now:`; const discoveredLimit = await this.fetchContextLimit(); if (typeof discoveredLimit === 'number') { this.maxContextTokens = discoveredLimit; - console.log('Cortensor session max context (tokens):', discoveredLimit); + debugLog('Cortensor session max context (tokens):', discoveredLimit); return; } - console.warn('Cortensor session response did not include a max context value. Falling back to 5K-token defaults.'); + console.warn( + `Cortensor session response did not include a max context value. Falling back to ${this.fallbackMaxContextTokens}-token defaults.` + ); } catch (error) { - console.warn('Unable to fetch Cortensor session limits via HTTP. Using fallback 5K-token configuration.'); + console.warn( + `Unable to fetch Cortensor session limits via HTTP. Using fallback ${this.fallbackMaxContextTokens}-token configuration.` + ); if (error instanceof Error) { console.warn(error.message); } @@ -650,7 +674,7 @@ Begin summary enhancement:`; } private shouldEnrich(summary: SummaryResult): boolean { - const minParagraphs = parseInt(process.env.MIN_SUMMARY_PARAGRAPHS || '3'); + const minParagraphs = env.MIN_SUMMARY_PARAGRAPHS; const paragraphCount = summary.summary.split('\n\n').length; return ( diff --git a/apps/Cortensor-Research-Summarizer/src/lib/env.ts b/apps/Cortensor-Research-Summarizer/src/lib/env.ts new file mode 100644 index 0000000..47d5f67 --- /dev/null +++ b/apps/Cortensor-Research-Summarizer/src/lib/env.ts @@ -0,0 +1,87 @@ +import 'server-only'; + +function optionalString(name: string): string | undefined { + const value = process.env[name]; + const trimmed = typeof value === 'string' ? value.trim() : ''; + return trimmed ? trimmed : undefined; +} + +function requiredString(name: string): string { + const value = optionalString(name); + if (!value) { + throw new Error(`${name} is required. Set it in .env.local and restart the server.`); + } + return value; +} + +function intWithDefault(name: string, defaultValue: number): number { + const raw = optionalString(name); + if (!raw) return defaultValue; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : defaultValue; +} + +function floatWithDefault(name: string, defaultValue: number): number { + const raw = optionalString(name); + if (!raw) return defaultValue; + const parsed = Number.parseFloat(raw); + return Number.isFinite(parsed) ? parsed : defaultValue; +} + +function boolWithDefault(name: string, defaultValue: boolean): boolean { + const raw = optionalString(name); + if (!raw) return defaultValue; + return ['1', 'true', 'yes', 'y', 'on'].includes(raw.toLowerCase()); +} + +function normalizeBaseUrl(value: string): string { + return value.trim().replace(/\/+$/, ''); +} + +export const env = { + // App + APP_DEBUG_LOGS: boolWithDefault('APP_DEBUG_LOGS', false), + MIN_SUMMARY_PARAGRAPHS: intWithDefault('MIN_SUMMARY_PARAGRAPHS', 3), + + // Cortensor router + CORTENSOR_API_KEY: requiredString('CORTENSOR_API_KEY'), + CORTENSOR_BASE_URL: normalizeBaseUrl(requiredString('CORTENSOR_BASE_URL')), + CORTENSOR_SESSION: intWithDefault('CORTENSOR_SESSION', 6), + + // Cortensor model/tuning + CORTENSOR_PROMPT_TYPE: intWithDefault('CORTENSOR_PROMPT_TYPE', 1), + CORTENSOR_TIMEOUT: intWithDefault('CORTENSOR_TIMEOUT', 300), + CORTENSOR_PRECOMMIT_TIMEOUT: intWithDefault('CORTENSOR_PRECOMMIT_TIMEOUT', 90), + CORTENSOR_MAX_TOKENS: intWithDefault('CORTENSOR_MAX_TOKENS', 6000), + CORTENSOR_FALLBACK_MAX_CONTEXT_TOKENS: intWithDefault('CORTENSOR_FALLBACK_MAX_CONTEXT_TOKENS', 5000), + CORTENSOR_PROMPT_TOKEN_RESERVE: intWithDefault('CORTENSOR_PROMPT_TOKEN_RESERVE', 1200), + CORTENSOR_MAX_CONTENT_CHARS: intWithDefault('CORTENSOR_MAX_CONTENT_CHARS', 9500), + CORTENSOR_AVG_CHARS_PER_TOKEN: floatWithDefault('CORTENSOR_AVG_CHARS_PER_TOKEN', 3), + CORTENSOR_TEMPERATURE: floatWithDefault('CORTENSOR_TEMPERATURE', 0.3), + CORTENSOR_TOP_P: floatWithDefault('CORTENSOR_TOP_P', 0.9), + CORTENSOR_TOP_K: intWithDefault('CORTENSOR_TOP_K', 40), + CORTENSOR_PRESENCE_PENALTY: floatWithDefault('CORTENSOR_PRESENCE_PENALTY', 0), + CORTENSOR_FREQUENCY_PENALTY: floatWithDefault('CORTENSOR_FREQUENCY_PENALTY', 0), + CORTENSOR_STREAM: boolWithDefault('CORTENSOR_STREAM', false), + + // Search providers + TAVILY_API_KEY: optionalString('TAVILY_API_KEY'), + GOOGLE_API_KEY: optionalString('GOOGLE_API_KEY'), + GOOGLE_SEARCH_ENGINE_ID: optionalString('GOOGLE_SEARCH_ENGINE_ID'), + + // News providers + NEWSAPI_API_KEY: optionalString('NEWSAPI_API_KEY'), + GNEWS_API_KEY: optionalString('GNEWS_API_KEY'), + MEDIASTACK_API_KEY: optionalString('MEDIASTACK_API_KEY'), + NASA_API_KEY: optionalString('NASA_API_KEY'), + + // Fetcher + ARTICLE_FETCH_TIMEOUT_MS: intWithDefault('ARTICLE_FETCH_TIMEOUT', 60000), + JINA_READER_BASE_URL: normalizeBaseUrl(optionalString('JINA_READER_BASE_URL') ?? 'https://r.jina.ai'), +} as const; + +export function debugLog(...args: unknown[]) { + if (env.APP_DEBUG_LOGS) { + console.log(...args); + } +} diff --git a/apps/Cortensor-Research-Summarizer/src/lib/historyDb.ts b/apps/Cortensor-Research-Summarizer/src/lib/historyDb.ts new file mode 100644 index 0000000..09a565d --- /dev/null +++ b/apps/Cortensor-Research-Summarizer/src/lib/historyDb.ts @@ -0,0 +1,201 @@ +import 'server-only'; + +import Database from 'better-sqlite3'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; + +export interface PersistedHistoryItem { + id: string; + timestamp: number; + url: string; + title: string; + author?: string; + publishDate?: string; + summary: string; + keyPoints: string[]; + wordCount: number; + wasEnriched: boolean; + previewText: string; +} + +function ensureDataDir(dirPath: string) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +function getDbPath(): string { + const dataDir = path.join(process.cwd(), '.data'); + ensureDataDir(dataDir); + return path.join(dataDir, 'history.sqlite'); +} + +let dbSingleton: Database.Database | null = null; + +function getDb(): Database.Database { + if (dbSingleton) return dbSingleton; + + const dbPath = getDbPath(); + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + + db.exec(` + CREATE TABLE IF NOT EXISTS history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + timestamp INTEGER NOT NULL, + url TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + publish_date TEXT, + summary TEXT NOT NULL, + key_points_json TEXT NOT NULL, + word_count INTEGER NOT NULL, + was_enriched INTEGER NOT NULL, + preview_text TEXT NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_history_user_url + ON history(user_id, url); + + CREATE INDEX IF NOT EXISTS idx_history_user_timestamp + ON history(user_id, timestamp DESC); + `); + + dbSingleton = db; + return db; +} + +function sha256(input: string): string { + return crypto.createHash('sha256').update(input).digest('hex'); +} + +function computeId(userId: string, url: string, timestamp: number): string { + // Stable-ish per URL to support upsert; still changes if timestamp changes. + // The unique index on (user_id, url) is the real upsert key. + return `${userId}_${sha256(url)}_${timestamp}`; +} + +export function createPreview(summary: string): string { + const cleaned = (summary || '').replace(/\s+/g, ' ').trim(); + return cleaned.length <= 150 ? cleaned : `${cleaned.slice(0, 150)}…`; +} + +export function upsertHistoryItem(userId: string, item: Omit<PersistedHistoryItem, 'id' | 'previewText'>): PersistedHistoryItem { + const db = getDb(); + const timestamp = item.timestamp ?? Date.now(); + const id = computeId(userId, item.url, timestamp); + const previewText = createPreview(item.summary); + + const stmt = db.prepare(` + INSERT INTO history ( + id, user_id, timestamp, url, title, author, publish_date, + summary, key_points_json, word_count, was_enriched, preview_text + ) VALUES ( + @id, @user_id, @timestamp, @url, @title, @author, @publish_date, + @summary, @key_points_json, @word_count, @was_enriched, @preview_text + ) + ON CONFLICT(user_id, url) DO UPDATE SET + id = excluded.id, + timestamp = excluded.timestamp, + title = excluded.title, + author = excluded.author, + publish_date = excluded.publish_date, + summary = excluded.summary, + key_points_json = excluded.key_points_json, + word_count = excluded.word_count, + was_enriched = excluded.was_enriched, + preview_text = excluded.preview_text + `); + + stmt.run({ + id, + user_id: userId, + timestamp, + url: item.url, + title: item.title, + author: item.author ?? null, + publish_date: item.publishDate ?? null, + summary: item.summary, + key_points_json: JSON.stringify(Array.isArray(item.keyPoints) ? item.keyPoints : []), + word_count: item.wordCount, + was_enriched: item.wasEnriched ? 1 : 0, + preview_text: previewText, + }); + + return { + id, + timestamp, + url: item.url, + title: item.title, + author: item.author, + publishDate: item.publishDate, + summary: item.summary, + keyPoints: Array.isArray(item.keyPoints) ? item.keyPoints : [], + wordCount: item.wordCount, + wasEnriched: !!item.wasEnriched, + previewText, + }; +} + +export function listHistory(userId: string, limit = 50): PersistedHistoryItem[] { + const db = getDb(); + const stmt = db.prepare(` + SELECT id, timestamp, url, title, author, publish_date, summary, + key_points_json, word_count, was_enriched, preview_text + FROM history + WHERE user_id = ? + ORDER BY timestamp DESC + LIMIT ? + `); + + const rows = stmt.all(userId, limit) as Array<{ + id: string; + timestamp: number; + url: string; + title: string; + author: string | null; + publish_date: string | null; + summary: string; + key_points_json: string; + word_count: number; + was_enriched: number; + preview_text: string; + }>; + + return rows.map((r) => ({ + id: r.id, + timestamp: Number(r.timestamp), + url: r.url, + title: r.title, + author: r.author ?? undefined, + publishDate: r.publish_date ?? undefined, + summary: r.summary, + keyPoints: (() => { + try { + const parsed = JSON.parse(r.key_points_json); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + })(), + wordCount: Number(r.word_count), + wasEnriched: !!r.was_enriched, + previewText: r.preview_text, + })); +} + +export function deleteHistoryItem(userId: string, id: string): boolean { + const db = getDb(); + const stmt = db.prepare(`DELETE FROM history WHERE user_id = ? AND id = ?`); + const res = stmt.run(userId, id); + return res.changes > 0; +} + +export function clearHistory(userId: string): number { + const db = getDb(); + const stmt = db.prepare(`DELETE FROM history WHERE user_id = ?`); + const res = stmt.run(userId); + return res.changes; +} diff --git a/apps/Cortensor-Research-Summarizer/src/lib/historyService.ts b/apps/Cortensor-Research-Summarizer/src/lib/historyService.ts index 0800e13..b67de40 100644 --- a/apps/Cortensor-Research-Summarizer/src/lib/historyService.ts +++ b/apps/Cortensor-Research-Summarizer/src/lib/historyService.ts @@ -16,89 +16,175 @@ export interface HistoryItem { const HISTORY_KEY = 'summarizer_history'; const MAX_HISTORY_ITEMS = 50; // Limit to prevent localStorage from getting too large +function createPreview(summary: string): string { + const cleanText = (summary || '').replace(/\s+/g, ' ').trim(); + return cleanText.length > 150 ? `${cleanText.substring(0, 150)}…` : cleanText; +} + +function safeParseHistory(value: string | null): HistoryItem[] { + if (!value) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? (parsed as HistoryItem[]) : []; + } catch { + return []; + } +} + +function sortNewestFirst(items: HistoryItem[]): HistoryItem[] { + return [...items].sort((a, b) => b.timestamp - a.timestamp); +} + +async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> { + const res = await fetch(url, init); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(text || res.statusText); + } + return (await res.json()) as T; +} + +function normalizeUserId(userId: string | null | undefined): string { + return (typeof userId === 'string' ? userId : '').trim(); +} + export class HistoryService { - static getHistory(): HistoryItem[] { + static getLocalHistory(): HistoryItem[] { try { - const stored = localStorage.getItem(HISTORY_KEY); - if (!stored) return []; - - const items = JSON.parse(stored) as HistoryItem[]; - // Sort by timestamp descending (newest first) - return items.sort((a, b) => b.timestamp - a.timestamp); + const items = safeParseHistory(localStorage.getItem(HISTORY_KEY)); + return sortNewestFirst(items).slice(0, MAX_HISTORY_ITEMS); } catch (error) { - console.error('Error loading history:', error); + console.error('Error loading local history:', error); return []; } } - static saveToHistory(item: Omit<HistoryItem, 'id' | 'timestamp' | 'previewText'>): void { + static async getHistory(userId?: string | null): Promise<HistoryItem[]> { + const uid = normalizeUserId(userId); + if (!uid) return this.getLocalHistory(); + try { - const history = this.getHistory(); - - // Create new history item - const newItem: HistoryItem = { - ...item, - id: this.generateId(), - timestamp: Date.now(), - previewText: this.createPreview(item.summary) - }; - - // Check if URL already exists (avoid duplicates) - const existingIndex = history.findIndex(h => h.url === item.url); - if (existingIndex !== -1) { - // Update existing item - history[existingIndex] = newItem; - } else { - // Add new item to beginning - history.unshift(newItem); - } - - // Limit history size - const limitedHistory = history.slice(0, MAX_HISTORY_ITEMS); - - localStorage.setItem(HISTORY_KEY, JSON.stringify(limitedHistory)); + const data = await fetchJson<{ items: HistoryItem[] }>(`/api/history?userId=${encodeURIComponent(uid)}`, { + cache: 'no-store' + }); + const items = Array.isArray(data.items) ? data.items : []; + return sortNewestFirst(items).slice(0, MAX_HISTORY_ITEMS); } catch (error) { - console.error('Error saving to history:', error); + console.warn('History API unavailable, falling back to local history:', error); + return this.getLocalHistory(); } } - static getHistoryItem(id: string): HistoryItem | null { - const history = this.getHistory(); + static saveLocalToHistory(item: Omit<HistoryItem, 'id' | 'timestamp' | 'previewText'>): HistoryItem { + const history = this.getLocalHistory(); + const newItem: HistoryItem = { + ...item, + id: this.generateId(), + timestamp: Date.now(), + previewText: createPreview(item.summary) + }; + + const existingIndex = history.findIndex(h => h.url === item.url); + if (existingIndex !== -1) { + history[existingIndex] = newItem; + } else { + history.unshift(newItem); + } + + const limited = history.slice(0, MAX_HISTORY_ITEMS); + localStorage.setItem(HISTORY_KEY, JSON.stringify(limited)); + return newItem; + } + + static async saveToHistory( + userId: string | null | undefined, + item: Omit<HistoryItem, 'id' | 'timestamp' | 'previewText'> + ): Promise<HistoryItem> { + const localItem = this.saveLocalToHistory(item); + const uid = normalizeUserId(userId); + if (!uid) return localItem; + + try { + const data = await fetchJson<{ success: boolean; item: HistoryItem }>(`/api/history`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: uid, + item: { + timestamp: localItem.timestamp, + url: item.url, + title: item.title, + author: item.author, + publishDate: item.publishDate, + summary: item.summary, + keyPoints: item.keyPoints, + wordCount: item.wordCount, + wasEnriched: item.wasEnriched + } + }) + }); + return data.item ?? localItem; + } catch (error) { + console.warn('Failed to persist history to server:', error); + return localItem; + } + } + + static getHistoryItemLocal(id: string): HistoryItem | null { + const history = this.getLocalHistory(); return history.find(item => item.id === id) || null; } - static deleteHistoryItem(id: string): void { + static deleteLocalHistoryItem(id: string): void { try { - const history = this.getHistory(); + const history = this.getLocalHistory(); const filtered = history.filter(item => item.id !== id); localStorage.setItem(HISTORY_KEY, JSON.stringify(filtered)); } catch (error) { - console.error('Error deleting history item:', error); + console.error('Error deleting local history item:', error); } } - static clearHistory(): void { + static async deleteHistoryItem(userId: string | null | undefined, id: string): Promise<void> { + this.deleteLocalHistoryItem(id); + const uid = normalizeUserId(userId); + if (!uid) return; + try { + await fetchJson(`/api/history`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: uid, id }) + }); + } catch (error) { + console.warn('Failed to delete history item on server:', error); + } + } + + static clearLocalHistory(): void { try { localStorage.removeItem(HISTORY_KEY); } catch (error) { - console.error('Error clearing history:', error); + console.error('Error clearing local history:', error); } } - static searchHistory(query: string): HistoryItem[] { - const history = this.getHistory(); - const lowercaseQuery = query.toLowerCase(); - - return history.filter(item => - item.title.toLowerCase().includes(lowercaseQuery) || - item.url.toLowerCase().includes(lowercaseQuery) || - item.summary.toLowerCase().includes(lowercaseQuery) || - (item.author && item.author.toLowerCase().includes(lowercaseQuery)) - ); + static async clearHistory(userId: string | null | undefined): Promise<void> { + this.clearLocalHistory(); + const uid = normalizeUserId(userId); + if (!uid) return; + try { + await fetchJson(`/api/history`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userId: uid, action: 'clear' }) + }); + } catch (error) { + console.warn('Failed to clear server history:', error); + } } static getHistoryStats(): { totalItems: number; totalArticles: number; oldestDate: Date | null } { - const history = this.getHistory(); + const history = this.getLocalHistory(); return { totalItems: history.length, totalArticles: history.length, @@ -109,9 +195,4 @@ export class HistoryService { private static generateId(): string { return Date.now().toString(36) + Math.random().toString(36).substr(2); } - - private static createPreview(summary: string): string { - const cleanText = summary.replace(/\n+/g, ' ').trim(); - return cleanText.length > 150 ? cleanText.substring(0, 147) + '...' : cleanText; - } } \ No newline at end of file diff --git a/apps/Cortensor-Research-Summarizer/src/lib/newsService.ts b/apps/Cortensor-Research-Summarizer/src/lib/newsService.ts index 9d73197..db967be 100644 --- a/apps/Cortensor-Research-Summarizer/src/lib/newsService.ts +++ b/apps/Cortensor-Research-Summarizer/src/lib/newsService.ts @@ -1,3 +1,5 @@ +import { env } from './env'; + type Category = 'general' | 'technology' | 'science' | 'random'; export interface NewsItem { @@ -11,8 +13,6 @@ export interface NewsItem { const NEWS_LIMIT = 6; -const getEnv = (key: string): string => process.env[key] || ''; - async function safeJsonFetch<T>(url: string, init?: RequestInit): Promise<T | null> { try { const res = await fetch(url, init); @@ -146,7 +146,7 @@ async function fetchPublicApisNews(): Promise<NewsItem[]> { })).filter((a) => a.url); } -async function fetchScienceSpotlight(nasaKey: string): Promise<NewsItem[]> { +async function fetchScienceSpotlight(nasaKey?: string): Promise<NewsItem[]> { const items: NewsItem[] = []; const nasa = await safeJsonFetch<any>(`https://api.nasa.gov/planetary/apod?api_key=${nasaKey || 'DEMO_KEY'}`); if (nasa?.url) { @@ -175,10 +175,10 @@ async function fetchScienceSpotlight(nasaKey: string): Promise<NewsItem[]> { } export async function fetchNews(category: Category): Promise<NewsItem[]> { - const newsApiKey = getEnv('NEWSAPI_API_KEY'); - const gnewsKey = getEnv('GNEWS_API_KEY'); - const mediastackKey = getEnv('MEDIASTACK_API_KEY'); - const nasaKey = getEnv('NASA_API_KEY'); + const newsApiKey = env.NEWSAPI_API_KEY; + const gnewsKey = env.GNEWS_API_KEY; + const mediastackKey = env.MEDIASTACK_API_KEY; + const nasaKey = env.NASA_API_KEY; if (category === 'random') { return fetchWikipediaRandom(); diff --git a/apps/Cortensor-Research-Summarizer/src/lib/searchService.ts b/apps/Cortensor-Research-Summarizer/src/lib/searchService.ts index 4d13877..2b78f76 100644 --- a/apps/Cortensor-Research-Summarizer/src/lib/searchService.ts +++ b/apps/Cortensor-Research-Summarizer/src/lib/searchService.ts @@ -1,5 +1,6 @@ import axios from 'axios'; import type { EnrichmentSource } from './cortensorService'; +import { env, debugLog } from './env'; interface TavilyResult { title?: string; @@ -28,9 +29,9 @@ export class SearchService { private tavilyApiKey: string; constructor() { - this.googleApiKey = process.env.GOOGLE_API_KEY || ''; - this.googleSearchEngineId = process.env.GOOGLE_SEARCH_ENGINE_ID || ''; - this.tavilyApiKey = process.env.TAVILY_API_KEY || ''; + this.googleApiKey = env.GOOGLE_API_KEY || ''; + this.googleSearchEngineId = env.GOOGLE_SEARCH_ENGINE_ID || ''; + this.tavilyApiKey = env.TAVILY_API_KEY || ''; } async searchAdditionalSources( @@ -43,10 +44,10 @@ export class SearchService { // Priority 1: Tavily (Primary - Working and reliable) if (this.tavilyApiKey) { try { - console.log('🔍 Searching with Tavily API (Primary search provider)...'); + debugLog('🔍 Searching with Tavily API (Primary search provider)...'); const tavilySources = await this.searchWithTavily(title, keywords); sources.push(...tavilySources); - console.log(`✅ Tavily successfully found ${tavilySources.length} relevant sources`); + debugLog(`✅ Tavily successfully found ${tavilySources.length} relevant sources`); } catch (error) { console.warn('❌ Tavily search failed:', error); } @@ -55,13 +56,13 @@ export class SearchService { // Priority 2: Google Custom Search (Backup - if Tavily insufficient) if (this.googleApiKey && this.googleSearchEngineId && sources.length < 3) { try { - console.log('🔍 Searching with Google Custom Search (Backup provider)...'); + debugLog('🔍 Searching with Google Custom Search (Backup provider)...'); const googleSources = await this.searchWithGoogle(title, keywords); if (googleSources.length > 0) { sources.push(...googleSources); - console.log(`✅ Google successfully found ${googleSources.length} additional sources`); + debugLog(`✅ Google successfully found ${googleSources.length} additional sources`); } else { - console.log('⚠️ Google Custom Search returned no results (search engine configuration may be required)'); + debugLog('⚠️ Google Custom Search returned no results (search engine configuration may be required)'); } } catch (error) { console.warn('❌ Google search failed:', error); @@ -70,7 +71,7 @@ export class SearchService { // Fallback: Use broader search terms if no sources found if (sources.length === 0) { - console.log('🔍 Attempting fallback search with broader terms...'); + debugLog('🔍 Attempting fallback search with broader terms...'); return this.fallbackSearch(title, originalUrl); } @@ -79,7 +80,7 @@ export class SearchService { sources.filter(source => source.url !== originalUrl) ).slice(0, 5); // Limit to 5 sources - console.log(`📋 Search completed: ${filteredSources.length} unique, relevant sources found for enhancement`); + debugLog(`📋 Search completed: ${filteredSources.length} unique, relevant sources found for enhancement`); return filteredSources; } @@ -167,7 +168,7 @@ export class SearchService { .slice(0, 2); if (broadTerms.length === 0) { - console.log('⚠️ No suitable fallback terms found for broader search'); + debugLog('⚠️ No suitable fallback terms found for broader search'); return []; } @@ -175,7 +176,7 @@ export class SearchService { if (this.tavilyApiKey) { try { const fallbackQuery = broadTerms.join(' OR '); - console.log(`🔍 Executing fallback Tavily search with query: "${fallbackQuery}"`); + debugLog(`🔍 Executing fallback Tavily search with query: "${fallbackQuery}"`); const response = await axios.post<TavilyResponse>( 'https://api.tavily.com/search', @@ -203,7 +204,7 @@ export class SearchService { snippet: result.content || result.snippet || '' })); - console.log(`✅ Fallback search successfully found ${fallbackSources.length} relevant sources`); + debugLog(`✅ Fallback search successfully found ${fallbackSources.length} relevant sources`); return fallbackSources; } } catch (error) { diff --git a/apps/Cortensor-Research-Summarizer/src/lib/urlFetcher.ts b/apps/Cortensor-Research-Summarizer/src/lib/urlFetcher.ts index fde80dc..5d2e7b4 100644 --- a/apps/Cortensor-Research-Summarizer/src/lib/urlFetcher.ts +++ b/apps/Cortensor-Research-Summarizer/src/lib/urlFetcher.ts @@ -1,4 +1,5 @@ -import axios from 'axios'; +import axios, { AxiosError } from 'axios'; +import { env } from './env'; export interface ArticleContent { title: string; @@ -12,83 +13,194 @@ export class URLFetcher { private static pdfParseLoader?: Promise<((data: Uint8Array | ArrayBuffer | Buffer | string | URL | number[]) => Promise<{ text: string; info?: { Title?: string; Author?: string } | undefined }>) | null>; async fetchArticle(url: string): Promise<ArticleContent> { - try { - // Validate URL - const parsedUrl = new URL(url); - const requestTimeout = parseInt(process.env.ARTICLE_FETCH_TIMEOUT || '60000', 10); - - const response = await axios.get(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', - 'Accept-Language': 'en-US,en;q=0.5', - 'Accept-Encoding': 'gzip, deflate', - 'DNT': '1', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1' - }, + // Validate URL early + const parsedUrl = new URL(url); + const timeoutMs = env.ARTICLE_FETCH_TIMEOUT_MS; + + const attempts: Array<{ + name: string; + url: string; + responseType: 'arraybuffer' | 'text'; + headers: Record<string, string>; + }> = [ + { + name: 'direct-browser', + url, responseType: 'arraybuffer', - timeout: Number.isFinite(requestTimeout) ? requestTimeout : 60000 - }); - - const buffer = Buffer.isBuffer(response.data) - ? response.data - : Buffer.from(response.data); - - const rawContentType = typeof response.headers['content-type'] === 'string' - ? response.headers['content-type'] - : Array.isArray(response.headers['content-type']) - ? response.headers['content-type'][0] - : ''; - const contentType = rawContentType.toLowerCase(); - const pathLower = parsedUrl.pathname.toLowerCase(); - const looksLikePdf = contentType.includes('application/pdf') || pathLower.endsWith('.pdf'); - - if (looksLikePdf) { - const pdfArticle = await this.extractPdfArticle(buffer, url); - if (pdfArticle) { - return pdfArticle; + headers: this.buildBrowserHeaders(parsedUrl) + }, + { + name: 'direct-browser+referer', + url, + responseType: 'arraybuffer', + headers: this.buildBrowserHeaders(parsedUrl, true) + }, + { + name: 'jina-proxy', + url: this.toJinaProxyUrl(url), + responseType: 'text', + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/plain, text/html;q=0.9, */*;q=0.8' + } + } + ]; + + let lastError: unknown; + + for (const attempt of attempts) { + try { + const response = await axios.get(attempt.url, { + headers: attempt.headers, + responseType: attempt.responseType, + timeout: timeoutMs, + maxRedirects: 5 + }); + + const rawContentType = typeof response.headers['content-type'] === 'string' + ? response.headers['content-type'] + : Array.isArray(response.headers['content-type']) + ? response.headers['content-type'][0] + : ''; + const contentType = rawContentType.toLowerCase(); + + const buffer = attempt.responseType === 'arraybuffer' + ? (Buffer.isBuffer(response.data) ? response.data : Buffer.from(response.data)) + : Buffer.from(typeof response.data === 'string' ? response.data : String(response.data), 'utf-8'); + + const pathLower = parsedUrl.pathname.toLowerCase(); + const looksLikePdf = contentType.includes('application/pdf') || pathLower.endsWith('.pdf'); + + if (looksLikePdf && attempt.responseType === 'arraybuffer') { + const pdfArticle = await this.extractPdfArticle(buffer, url); + if (pdfArticle) { + return pdfArticle; + } + + return { + title: 'PDF Document', + content: 'Unable to extract readable text from this PDF using built-in parsers. The document may be scanned or contain unsupported encoding.', + url, + author: undefined, + publishDate: undefined + }; + } + + const rawText = attempt.responseType === 'arraybuffer' + ? this.decodeBufferToString(buffer, contentType) + : buffer.toString('utf-8'); + + const isLikelyHtml = /<\s*html\b|<\s*body\b|<\s*article\b|<\s*main\b|<\s*div\b|<\s*p\b/i.test(rawText); + const title = this.extractTitle(rawText) || this.deriveTitleFromText(rawText) || 'Untitled Article'; + const content = isLikelyHtml ? this.extractContent(rawText) : this.cleanText(rawText); + const author = isLikelyHtml ? this.extractAuthor(rawText) : undefined; + const publishDate = isLikelyHtml ? this.extractPublishDate(rawText) : undefined; + + if (!content.trim()) { + throw new Error(`No content could be extracted (${attempt.name})`); } return { - title: 'PDF Document', - content: 'Unable to extract readable text from this PDF using built-in parsers. The document may be scanned or contain unsupported encoding.', + title, + content: content.trim(), url, - author: undefined, - publishDate: undefined + author, + publishDate }; + } catch (error) { + lastError = error; + + const status = this.getAxiosStatus(error); + // If this is a hard 4xx other than 403/429, further attempts are unlikely to help. + if (typeof status === 'number' && status >= 400 && status < 500 && status !== 403 && status !== 429) { + break; + } } + } - const html = this.decodeBufferToString(buffer, contentType); + const status = this.getAxiosStatus(lastError); + const statusNote = typeof status === 'number' ? ` (status ${status})` : ''; + const message = this.getErrorMessage(lastError); + throw new Error(`Failed to fetch article${statusNote}: ${message}`); + } - // Extract title using regex - const title = this.extractTitle(html); - - // Extract main content using regex - const content = this.extractContent(html); - - // Extract metadata - const author = this.extractAuthor(html); - const publishDate = this.extractPublishDate(html); - - if (!content.trim()) { - throw new Error('No content could be extracted from the URL'); - } - - return { - title: title || 'Untitled Article', - content: content.trim(), - url, - author, - publishDate - }; - - } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to fetch article: ${error.message}`); + private buildBrowserHeaders(parsedUrl: URL, includeReferer = false): Record<string, string> { + const headers: Record<string, string> = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate', + 'Upgrade-Insecure-Requests': '1', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + }; + + if (includeReferer) { + headers['Referer'] = `${parsedUrl.origin}/`; + headers['Origin'] = parsedUrl.origin; + } + + return headers; + } + + private toJinaProxyUrl(originalUrl: string): string { + // Jina AI "reader" proxy often bypasses basic anti-bot restrictions. + // Format: https://r.jina.ai/https://example.com/path + return `${env.JINA_READER_BASE_URL}/${originalUrl}`; + } + + private getAxiosStatus(error: unknown): number | undefined { + if (!error) return undefined; + if (axios.isAxiosError(error)) { + return error.response?.status; + } + return undefined; + } + + private getErrorMessage(error: unknown): string { + if (!error) return 'Unknown error'; + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const status = axiosError.response?.status; + const statusText = axiosError.response?.statusText; + if (status && statusText) return `${status} ${statusText}`; + return axiosError.message; + } + try { + return JSON.stringify(error); + } catch { + return 'Unknown error'; + } + } + + private deriveTitleFromText(text: string): string { + const normalized = text + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .trim(); + + // Handle common "Title:" style outputs (e.g., some proxy/readability services) + const titleLine = normalized.match(/^(?:\s*#\s*)?title\s*[:\-]\s*(.+)$/im); + if (titleLine?.[1]) { + const candidate = this.cleanText(titleLine[1]); + if (candidate.length >= 8) return candidate; + } + + const lines = normalized + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + + for (const line of lines.slice(0, 20)) { + const cleaned = this.cleanText(line); + if (cleaned.length >= 8 && cleaned.length <= 160) { + return cleaned; } - throw new Error('Failed to fetch article: Unknown error'); } + + return ''; } private decodeBufferToString(buffer: Buffer, contentType: string): string { diff --git a/apps/Cortensor-XGenBot/README.md b/apps/Cortensor-XGenBot/README.md index b6d52c7..a980947 100644 --- a/apps/Cortensor-XGenBot/README.md +++ b/apps/Cortensor-XGenBot/README.md @@ -61,8 +61,8 @@ Cortensor-XGenBot is a powerful Telegram bot designed to generate engaging conte CORTENSOR_API_URL=your_cortensor_api_url CORTENSOR_API_KEY=your_cortensor_api_key CORTENSOR_SESSION_ID=your_cortensor_session_id - MODEL_PROVIDER=deepseek - MODEL_NAME=deepseek-r1 + MODEL_PROVIDER=gpt-oss + MODEL_NAME=gpt-oss-20b DEFAULT_TONE=concise DEFAULT_HASHTAGS=#AI #Tech ``` diff --git a/apps/Cortensor-XGenBot/src/bot.py b/apps/Cortensor-XGenBot/src/bot.py index df4b0e1..65ca83e 100644 --- a/apps/Cortensor-XGenBot/src/bot.py +++ b/apps/Cortensor-XGenBot/src/bot.py @@ -19,12 +19,58 @@ # from .cortensor_api import router_info, router_status, _build_endpoint # removed with /diag from .hashtags import suggest_hashtags from .link_fetch import parse_x_status_id, fetch_x_tweet_json, build_reply_context_from_x, fetch_x_tweet_json_from_text, build_basic_context_from_url +from .rate_limiter import rate_limit_check, record_user_action logger = logging.getLogger(__name__) -_TONES = ["concise", "informative", "persuasive", "technical", "conversational", "authoritative"] +# Use tones from config (loaded from .env) +_TONES = [] # Will be populated from config in _get_tones() _SESS: dict[str, dict] = {} +# Session cleanup settings (configurable via env) +def _get_int_env(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except (ValueError, TypeError): + return default + +_SESSION_TTL_HOURS = _get_int_env('SESSION_TTL_HOURS', 24) +_SESSION_CLEANUP_INTERVAL = _get_int_env('SESSION_CLEANUP_INTERVAL', 3600) +_LAST_CLEANUP_TIME = 0 + +def _cleanup_old_sessions(): + """Remove sessions older than TTL to prevent memory leak""" + global _LAST_CLEANUP_TIME + import time + now = time.time() + + # Only run cleanup if enough time has passed + if now - _LAST_CLEANUP_TIME < _SESSION_CLEANUP_INTERVAL: + return + + _LAST_CLEANUP_TIME = now + ttl_seconds = _SESSION_TTL_HOURS * 3600 + expired_uids = [] + + for uid, sess in _SESS.items(): + created_at = sess.get("_created_at", 0) + if created_at and (now - created_at) > ttl_seconds: + expired_uids.append(uid) + + for uid in expired_uids: + try: + del _SESS[uid] + logger.debug(f"Cleaned up expired session for user {uid}") + except Exception: + pass + + if expired_uids: + logger.info(f"Session cleanup: removed {len(expired_uids)} expired sessions") + +def _get_tones() -> list[str]: + """Get available tones from config (supports runtime changes)""" + return getattr(_cfg, 'AVAILABLE_TONES', ['concise', 'informative', 'persuasive', 'technical', 'conversational', 'authoritative']) + _IP_RE = re.compile(r"(?:\d{1,3}\.){3}\d{1,3}") _HOST_FIELD_RE = re.compile(r"host='[^']+'", re.I) @@ -50,16 +96,52 @@ def _audit(uid: str, event: str, **fields): except Exception: pass + +def _check_rate_limit(uid: str, update: Update, is_generation: bool = False) -> bool: + """ + Check rate limit and send error message if exceeded. + Returns True if allowed, False if rate limited. + """ + allowed, msg = rate_limit_check(uid, is_generation=is_generation) + if not allowed: + try: + update.message.reply_text(f"⏳ {msg}") + except Exception: + pass + _audit(uid, "rate_limited", is_generation=is_generation) + return False + return True + + +def _check_rate_limit_callback(uid: str, query, is_generation: bool = False) -> bool: + """ + Check rate limit for callback queries. + Returns True if allowed, False if rate limited. + """ + allowed, msg = rate_limit_check(uid, is_generation=is_generation) + if not allowed: + try: + query.answer(f"⏳ {msg}", show_alert=True) + except Exception: + pass + _audit(uid, "rate_limited", is_generation=is_generation) + return False + return True + + def _sess(uid: str) -> dict: + # Run periodic cleanup to prevent memory leaks + _cleanup_old_sessions() + if uid in _SESS: return _SESS[uid] # Load defaults from DB if available db_defaults = load_user_defaults(uid) or {} env_defaults = { - "tone": os.getenv("DEFAULT_TONE", "concise"), - "length": "medium", - "n": 6, - "hashtags": os.getenv("DEFAULT_HASHTAGS", "").strip(), + "tone": getattr(_cfg, 'DEFAULT_TONE', 'concise'), + "length": getattr(_cfg, 'DEFAULT_LENGTH', 'medium'), + "n": getattr(_cfg, 'DEFAULT_THREAD_N', 6), + "hashtags": getattr(_cfg, 'DEFAULT_HASHTAGS', '').strip(), } dfl = {**env_defaults, **db_defaults} _SESS[uid] = { @@ -78,8 +160,12 @@ def _sess(uid: str) -> dict: "style_stats": False, "voice": None, "_uid": uid, + "char_limit": getattr(_cfg, 'TWEET_CHAR_LIMIT', 280), # Per-user char limit (avoids race condition) + "_created_at": None, # For session cleanup "defaults": dfl, } + import time + _SESS[uid]["_created_at"] = time.time() return _SESS[uid] @@ -90,7 +176,7 @@ def _build_preview(sess: dict, max_len: int | None = None) -> str: if max_len is None: max_len = getattr(_cfg, 'PREVIEW_CHAR_LIMIT', 3900) mode = sess.get("mode", "thread") - tone = sess.get("tone", os.getenv("DEFAULT_TONE", "concise")) + tone = sess.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')) if mode == "tweet": header = ( f"Tweet Preview\n" @@ -152,12 +238,9 @@ def _send_thread_preview(context: CallbackContext, chat_id: int, sess: dict): pass -_CHAR_LIMIT_PRESETS = [280, 400, 600, 800, 1000] - - def _next_char_limit(current: int) -> int: try: - arr = _CHAR_LIMIT_PRESETS + arr = getattr(_cfg, 'CHAR_LIMIT_PRESETS', [280, 400, 600, 800, 1000]) if current not in arr: # find closest arr_sorted = sorted(arr) @@ -173,12 +256,12 @@ def _next_char_limit(current: int) -> int: def _kb(sess: dict) -> InlineKeyboardMarkup: mode = sess.get("mode", "thread") - n = int(sess.get("n", 6)) - tone = sess.get("tone", os.getenv("DEFAULT_TONE", "concise")) + n = int(sess.get("n", getattr(_cfg, 'DEFAULT_THREAD_N', 6))) + tone = sess.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')) tags_on = bool(sess.get("hashtags")) posts = sess.get("posts", []) or [] # Build the X compose URL. For replies, prefer in_reply_to=<id> if we can extract one. - first_url = "https://x.com/intent/tweet" + first_url = getattr(_cfg, 'X_COMPOSE_URL', 'https://x.com/intent/tweet') reply_sid = None if mode == "reply": # Try to extract status id from stored context text @@ -209,7 +292,7 @@ def _kb(sess: dict) -> InlineKeyboardMarkup: ]) rows.append([ InlineKeyboardButton(f"Length: {sess.get('length','medium')}", callback_data="thr|length|next"), - InlineKeyboardButton(f"Max: {getattr(_cfg,'TWEET_CHAR_LIMIT',280)}", callback_data="thr|climit|next"), + InlineKeyboardButton(f"Max: {sess.get('char_limit', getattr(_cfg,'TWEET_CHAR_LIMIT',280))}", callback_data="thr|climit|next"), ]) rows.append([ InlineKeyboardButton(f"CTA: {'ON' if sess.get('style_cta') else 'OFF'}", callback_data="thr|style|cta"), @@ -236,7 +319,7 @@ def _kb(sess: dict) -> InlineKeyboardMarkup: rows.append([ InlineKeyboardButton(f"Tone: {tone}", callback_data="thr|tone|next"), InlineKeyboardButton(f"Length: {sess.get('length','medium')}", callback_data="thr|length|next"), - InlineKeyboardButton(f"Max: {getattr(_cfg,'TWEET_CHAR_LIMIT',280)}", callback_data="thr|climit|next"), + InlineKeyboardButton(f"Max: {sess.get('char_limit', getattr(_cfg,'TWEET_CHAR_LIMIT',280))}", callback_data="thr|climit|next"), ]) rows.append([ InlineKeyboardButton(f"CTA: {'ON' if sess.get('style_cta') else 'OFF'}", callback_data="thr|style|cta"), @@ -265,6 +348,7 @@ def _reply_kb(sess: dict | None = None) -> ReplyKeyboardMarkup: step = (sess or {}).get("wizard_step") rows: list[list[str]] mode = (sess or {}).get("mode", "thread") + tones = _get_tones() if step == "topic": switch_label = "Switch: Tweet" if mode == "thread" else "Switch: Thread" rows = [["/start"], [switch_label, "Switch: Reply"], ["Length: short", "Length: medium", "Length: long", "Length: auto"]] @@ -273,8 +357,8 @@ def _reply_kb(sess: dict | None = None) -> ReplyKeyboardMarkup: elif step == "n": rows = [["5", "6", "8", "10"], ["Skip", "/start"]] elif step == "tone": - mid = (len(_TONES) + 1) // 2 - rows = [_TONES[:mid], _TONES[mid:], ["Skip", "/start"]] + mid = (len(tones) + 1) // 2 + rows = [tones[:mid], tones[mid:], ["Skip", "/start"]] elif step == "tags": rows = [["Use default", "No tags"], ["Skip", "/start"], ["Generate now", "Back"]] elif step == "reply_ctx": @@ -287,6 +371,12 @@ def _reply_kb(sess: dict | None = None) -> ReplyKeyboardMarkup: def thread_command(update: Update, context: CallbackContext): + uid = str(update.effective_user.id) + + # Rate limit check for generation + if not _check_rate_limit(uid, update, is_generation=True): + return + text = update.message.text or "" parts = text.split(maxsplit=1) brief = parts[1].strip() if len(parts) > 1 else "" @@ -309,6 +399,8 @@ def thread_command(update: Update, context: CallbackContext): pass loading_msg = update.message.reply_text("Generating thread… ⏳") posts = generate_thread(topic=topic, n_posts=n, tone=tone, hashtags=hashtags, instructions="", length="medium") + # Record successful generation for rate limiting + record_user_action(uid, is_generation=True) except Exception as e: try: if loading_msg: @@ -324,13 +416,12 @@ def thread_command(update: Update, context: CallbackContext): except Exception: pass - uid = str(update.effective_user.id) sess = _sess(uid) sess.update({ "mode": "thread", "topic": topic, "n": n, - "tone": tone or sess.get("tone", os.getenv("DEFAULT_TONE", "concise")), + "tone": tone or sess.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')), "hashtags": hashtags, "posts": posts, }) @@ -364,17 +455,21 @@ def _thread_callback(update: Update, context: CallbackContext): pass q.answer("Closed"); return if act == "n": + max_posts = getattr(_cfg, 'MAX_THREAD_POSTS', 25) + min_posts = getattr(_cfg, 'MIN_THREAD_POSTS', 2) + default_n = getattr(_cfg, 'DEFAULT_THREAD_N', 6) if len(data) > 2 and data[2] == "inc": - sess["n"] = min(25, int(sess.get("n", 6)) + 1) + sess["n"] = min(max_posts, int(sess.get("n", default_n)) + 1) elif len(data) > 2 and data[2] == "dec": - sess["n"] = max(2, int(sess.get("n", 6)) - 1) + sess["n"] = max(min_posts, int(sess.get("n", default_n)) - 1) elif act == "tone": - cur = sess.get("tone", os.getenv("DEFAULT_TONE", "concise")) + tones = _get_tones() + cur = sess.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')) try: - idx = (_TONES.index(cur) + 1) % len(_TONES) + idx = (tones.index(cur) + 1) % len(tones) except ValueError: idx = 0 - sess["tone"] = _TONES[idx] + sess["tone"] = tones[idx] elif act == "length": order = ["short", "medium", "long", "auto"] cur = (sess.get("length", "medium") or "medium").lower() @@ -391,7 +486,7 @@ def _thread_callback(update: Update, context: CallbackContext): elif act == "tags": sub = data[2] if len(data) > 2 else "toggle" if sub == "toggle": - sess["hashtags"] = "" if sess.get("hashtags") else os.getenv("DEFAULT_HASHTAGS", "").strip() + sess["hashtags"] = "" if sess.get("hashtags") else getattr(_cfg, 'DEFAULT_HASHTAGS', '').strip() elif sub == "suggest": topic = sess.get("topic", "") posts = sess.get("posts", []) or [] @@ -410,14 +505,10 @@ def _thread_callback(update: Update, context: CallbackContext): context.bot.send_message(chat_id=q.message.chat_id, text=_build_preview(sess), reply_markup=_kb(sess)) return elif act == "climit": - # Cycle character limit for generation & editing; does not retro-trim existing posts - cur = getattr(_cfg, 'TWEET_CHAR_LIMIT', 280) + # Cycle character limit per-session (avoids race condition with global config) + cur = sess.get("char_limit", getattr(_cfg, 'TWEET_CHAR_LIMIT', 280)) new_val = _next_char_limit(cur) - # Mutate module attribute so subsequent functions pick it up - try: - _cfg.TWEET_CHAR_LIMIT = new_val - except Exception: - pass + sess["char_limit"] = new_val try: q.answer(f"Max chars set: {new_val}") except Exception: @@ -505,6 +596,10 @@ def _thread_callback(update: Update, context: CallbackContext): context.bot.send_message(chat_id=q.message.chat_id, text=msg) q.answer("Edit mode on"); return elif act == "regen": + # Rate limit check for regeneration + if not _check_rate_limit_callback(uid, q, is_generation=True): + return + loading_msg = None try: try: @@ -557,6 +652,8 @@ def _thread_callback(update: Update, context: CallbackContext): length=sess.get("length", "medium"), offset=0, ) + # Record successful generation for rate limiting + record_user_action(uid, is_generation=True) _audit(uid, "regen_done", ok=bool(sess.get("posts")), count=len(sess.get("posts", []))) # If split send is enabled for threads, push a fresh preview instead of editing original message if mode == 'thread' and getattr(_cfg, 'THREAD_SPLIT_SEND', False): @@ -577,6 +674,10 @@ def _thread_callback(update: Update, context: CallbackContext): except Exception: pass elif act == "cont": + # Rate limit check for continue generation + if not _check_rate_limit_callback(uid, q, is_generation=True): + return + # Continue thread: generate next batch using offset = current length try: existing = sess.get("posts", []) or [] @@ -592,6 +693,8 @@ def _thread_callback(update: Update, context: CallbackContext): offset=len(existing), ) sess["posts"] = existing + new_posts + # Record successful generation for rate limiting + record_user_action(uid, is_generation=True) q.answer(f"Added {len(new_posts)} posts") if getattr(_cfg, 'THREAD_SPLIT_SEND', False): _send_thread_preview(context, q.message.chat.id, sess) @@ -599,20 +702,6 @@ def _thread_callback(update: Update, context: CallbackContext): except Exception as e: q.answer(f"Fail: {_friendly_error(e)}", show_alert=True) return - except Exception as e: - try: - if loading_msg: - context.bot.delete_message(chat_id=loading_msg.chat_id, message_id=loading_msg.message_id) - except Exception: - pass - q.answer(f"Failed: {_friendly_error(e)}", show_alert=True) - return - finally: - try: - if loading_msg: - context.bot.delete_message(chat_id=loading_msg.chat_id, message_id=loading_msg.message_id) - except Exception: - pass elif act == "rline": posts = sess.get("posts", []) or [] if not posts: @@ -690,10 +779,11 @@ def history_command(update: Update, context: CallbackContext): def _settings_kb(sess: dict | None = None) -> ReplyKeyboardMarkup: + tones = _get_tones() rows = [ ["Done", "Cancel"], - _TONES[: (len(_TONES)+1)//2], - _TONES[(len(_TONES)+1)//2 :], + tones[: (len(tones)+1)//2], + tones[(len(tones)+1)//2 :], ["Length: short", "Length: medium", "Length: long", "Length: auto"], ["2", "3", "5", "6", "8", "10"], ["Use default tags", "No tags"], @@ -709,9 +799,9 @@ def settings_command(update: Update, context: CallbackContext): _audit(uid, "settings_open") msg = ( "Settings (defaults):\n" - f"- Tone: {d.get('tone','concise')}\n" - f"- Length: {d.get('length','medium')}\n" - f"- N (thread): {d.get('n',6)}\n" + f"- Tone: {d.get('tone', getattr(_cfg, 'DEFAULT_TONE', 'concise'))}\n" + f"- Length: {d.get('length', getattr(_cfg, 'DEFAULT_LENGTH', 'medium'))}\n" + f"- N (thread): {d.get('n', getattr(_cfg, 'DEFAULT_THREAD_N', 6))}\n" f"- Tags: {d.get('hashtags','') or '(none)'}\n\n" "Pick a tone, set Length: <value>, send a number for N (2–25), or type tags.\n" "Tap Done to save." @@ -721,8 +811,8 @@ def settings_command(update: Update, context: CallbackContext): def _build_help_text() -> str: - default_tone = os.getenv("DEFAULT_TONE", "concise").strip() - default_tags = os.getenv("DEFAULT_HASHTAGS", "").strip() or "(none)" + default_tone = getattr(_cfg, 'DEFAULT_TONE', 'concise').strip() + default_tags = getattr(_cfg, 'DEFAULT_HASHTAGS', '').strip() or "(none)" return ( "Help\n" @@ -737,7 +827,7 @@ def _build_help_text() -> str: def _build_start_text() -> str: - default_tone = os.getenv("DEFAULT_TONE", "concise").strip() + default_tone = getattr(_cfg, 'DEFAULT_TONE', 'concise').strip() return ( "Welcome to the Tweet/Thread Generator\n\n" "Getting started:\n" @@ -814,8 +904,8 @@ def _cmd_callback(update: Update, context: CallbackContext): sess.clear(); sess['_uid'] = uid sess["wizard_step"] = "topic"; sess["mode"] = "tweet" d = _sess(uid).get("defaults", {}) - sess["tone"] = d.get("tone", os.getenv("DEFAULT_TONE", "concise")) - sess["length"] = d.get("length", "medium") + sess["tone"] = d.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')) + sess["length"] = d.get("length", getattr(_cfg, 'DEFAULT_LENGTH', 'medium')) try: q.edit_message_text("Send a topic for your tweet.") except Exception: @@ -829,8 +919,8 @@ def _cmd_callback(update: Update, context: CallbackContext): sess.clear(); sess['_uid'] = uid sess["wizard_step"] = "reply_ctx"; sess["mode"] = "reply" d = _sess(uid).get("defaults", {}) - sess["tone"] = d.get("tone", os.getenv("DEFAULT_TONE", "concise")) - sess["length"] = d.get("length", "medium") + sess["tone"] = d.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')) + sess["length"] = d.get("length", getattr(_cfg, 'DEFAULT_LENGTH', 'medium')) try: q.edit_message_text("Send the X post text (or paste link and provide the text).") except Exception: @@ -866,12 +956,13 @@ def handle_text_input(update: Update, context: CallbackContext): if sess.get("settings_mode"): d = sess.setdefault("defaults", { - "tone": os.getenv("DEFAULT_TONE", "concise"), - "length": "medium", - "n": 6, - "hashtags": os.getenv("DEFAULT_HASHTAGS", "").strip(), + "tone": getattr(_cfg, 'DEFAULT_TONE', 'concise'), + "length": getattr(_cfg, 'DEFAULT_LENGTH', 'medium'), + "n": getattr(_cfg, 'DEFAULT_THREAD_N', 6), + "hashtags": getattr(_cfg, 'DEFAULT_HASHTAGS', '').strip(), }) low = text.lower() + tones = _get_tones() if low in ("done", "exit", "cancel", "back"): sess.pop("settings_mode", None) # Persist user defaults @@ -882,7 +973,7 @@ def handle_text_input(update: Update, context: CallbackContext): update.message.reply_text("Settings saved.", reply_markup=_reply_kb(sess)) _audit(uid, "settings_saved", tone=d.get("tone"), length=d.get("length"), n=d.get("n"), tags=bool(d.get("hashtags"))) return - if text in _TONES: + if text in tones: d["tone"] = text try: save_user_defaults(uid, d) @@ -904,7 +995,9 @@ def handle_text_input(update: Update, context: CallbackContext): return if text.isdigit(): v = int(text) - if 2 <= v <= 25: + min_n = getattr(_cfg, 'MIN_THREAD_POSTS', 2) + max_n = getattr(_cfg, 'MAX_THREAD_POSTS', 25) + if min_n <= v <= max_n: d["n"] = v try: save_user_defaults(uid, d) @@ -914,7 +1007,7 @@ def handle_text_input(update: Update, context: CallbackContext): _audit(uid, "settings_n", value=v) return if low in ("use default tags", "use default"): - d["hashtags"] = os.getenv("DEFAULT_HASHTAGS", "").strip() + d["hashtags"] = getattr(_cfg, 'DEFAULT_HASHTAGS', '').strip() try: save_user_defaults(uid, d) except Exception: @@ -940,7 +1033,9 @@ def handle_text_input(update: Update, context: CallbackContext): update.message.reply_text("Default tags updated.", reply_markup=_settings_kb(sess)) _audit(uid, "settings_tags_set", value=text.strip()) return - update.message.reply_text("Pick a tone, Length: <value>, a number for N (2-25), or type tags with #.", reply_markup=_settings_kb(sess)) + min_n = getattr(_cfg, 'MIN_THREAD_POSTS', 2) + max_n = getattr(_cfg, 'MAX_THREAD_POSTS', 25) + update.message.reply_text(f"Pick a tone, Length: <value>, a number for N ({min_n}-{max_n}), or type tags with #.", reply_markup=_settings_kb(sess)) return if sess.get("regen_line_mode"): @@ -998,10 +1093,10 @@ def handle_text_input(update: Update, context: CallbackContext): sess["wizard_step"] = "topic" sess["mode"] = "thread" d = _sess(uid).get("defaults", {}) - sess["tone"] = d.get("tone", os.getenv("DEFAULT_TONE", "concise")) - sess["length"] = d.get("length", "medium") - sess["n"] = int(d.get("n", 6)) - sess["hashtags"] = d.get("hashtags", os.getenv("DEFAULT_HASHTAGS", "").strip()) + sess["tone"] = d.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')) + sess["length"] = d.get("length", getattr(_cfg, 'DEFAULT_LENGTH', 'medium')) + sess["n"] = int(d.get("n", getattr(_cfg, 'DEFAULT_THREAD_N', 6))) + sess["hashtags"] = d.get("hashtags", getattr(_cfg, 'DEFAULT_HASHTAGS', '').strip()) update.message.reply_text("Send a topic for your thread (just text).", reply_markup=_reply_kb(sess)) update.message.reply_text("Topic:", reply_markup=ForceReply(selective=True)) _audit(uid, "wizard_thread_start") @@ -1011,8 +1106,8 @@ def handle_text_input(update: Update, context: CallbackContext): sess["wizard_step"] = "topic" sess["mode"] = "tweet" d = _sess(uid).get("defaults", {}) - sess["tone"] = d.get("tone", os.getenv("DEFAULT_TONE", "concise")) - sess["length"] = d.get("length", "medium") + sess["tone"] = d.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')) + sess["length"] = d.get("length", getattr(_cfg, 'DEFAULT_LENGTH', 'medium')) update.message.reply_text("Send a topic for your tweet (just text).", reply_markup=_reply_kb(sess)) update.message.reply_text("Topic:", reply_markup=ForceReply(selective=True)) _audit(uid, "wizard_tweet_start") @@ -1022,8 +1117,8 @@ def handle_text_input(update: Update, context: CallbackContext): sess["wizard_step"] = "reply_ctx" sess["mode"] = "reply" d = _sess(uid).get("defaults", {}) - sess["tone"] = d.get("tone", os.getenv("DEFAULT_TONE", "concise")) - sess["length"] = d.get("length", "medium") + sess["tone"] = d.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')) + sess["length"] = d.get("length", getattr(_cfg, 'DEFAULT_LENGTH', 'medium')) update.message.reply_text("Send the X post text (or paste a link).", reply_markup=_reply_kb(sess)) update.message.reply_text("Paste here:", reply_markup=ForceReply(selective=True)) _audit(uid, "wizard_reply_start") @@ -1034,7 +1129,7 @@ def handle_text_input(update: Update, context: CallbackContext): if sess.get("wizard_step") == "length_pick": sess["wizard_step"] = "tone" update.message.reply_text( - f"Pick a tone (default {os.getenv('DEFAULT_TONE','concise')}).", + f"Pick a tone (default {getattr(_cfg, 'DEFAULT_TONE', 'concise')}).", reply_markup=_reply_kb(sess) ) else: @@ -1090,8 +1185,8 @@ def handle_text_input(update: Update, context: CallbackContext): sess.clear() sess["mode"] = "reply" d = _sess(uid).get("defaults", {}) - sess["tone"] = d.get("tone", os.getenv("DEFAULT_TONE", "concise")) - sess["length"] = d.get("length", "medium") + sess["tone"] = d.get("tone", getattr(_cfg, 'DEFAULT_TONE', 'concise')) + sess["length"] = d.get("length", getattr(_cfg, 'DEFAULT_LENGTH', 'medium')) sess["reply_ctx"] = ctx_txt sess["wizard_step"] = "length_pick" _audit(uid, "reply_context_parsed", has=bool(data)) @@ -1161,7 +1256,7 @@ def handle_text_input(update: Update, context: CallbackContext): if sess.get("mode") == "tweet": sess["wizard_step"] = "tone" update.message.reply_text( - f"Pick a tone (default {os.getenv('DEFAULT_TONE','concise')}).", + f"Pick a tone (default {getattr(_cfg, 'DEFAULT_TONE', 'concise')}).", reply_markup=_reply_kb(sess) ) elif sess.get("mode") == "thread": @@ -1200,7 +1295,7 @@ def handle_text_input(update: Update, context: CallbackContext): ) return sess["wizard_step"] = "tone" - default_tone = os.getenv("DEFAULT_TONE", "concise").strip() + default_tone = getattr(_cfg, 'DEFAULT_TONE', 'concise').strip() update.message.reply_text( f"Pick a tone (default {default_tone}) or type your own.", reply_markup=_reply_kb(sess) ) @@ -1208,13 +1303,13 @@ def handle_text_input(update: Update, context: CallbackContext): if step == "tone": if text.lower() == "skip": - sess["tone"] = os.getenv("DEFAULT_TONE", "concise").strip() + sess["tone"] = getattr(_cfg, 'DEFAULT_TONE', 'concise').strip() else: sess["tone"] = text if sess.get("mode") == "tweet": topic = sess.get("topic", "") - tone = sess.get("tone") or os.getenv("DEFAULT_TONE", "concise").strip() - length = sess.get("length", "medium") + tone = sess.get("tone") or getattr(_cfg, 'DEFAULT_TONE', 'concise').strip() + length = sess.get("length", getattr(_cfg, 'DEFAULT_LENGTH', 'medium')) # Build guidance from style/voice instr = (sess.get("instructions", "") or "").strip() hints = [] @@ -1271,8 +1366,8 @@ def handle_text_input(update: Update, context: CallbackContext): return elif sess.get("mode") == "reply": # On first tone selection in reply flow, generate immediately - tone = sess.get("tone") or os.getenv("DEFAULT_TONE", "concise").strip() - length = sess.get("length", "medium") + tone = sess.get("tone") or getattr(_cfg, 'DEFAULT_TONE', 'concise').strip() + length = sess.get("length", getattr(_cfg, 'DEFAULT_LENGTH', 'medium')) instr = (sess.get("instructions", "") or "").strip() hints = [] if sess.get("style_cta"): hints.append("Include a clear call-to-action.") @@ -1332,7 +1427,7 @@ def handle_text_input(update: Update, context: CallbackContext): return # Thread flow continues to tags sess["wizard_step"] = "tags" - default_tags = os.getenv("DEFAULT_HASHTAGS", "").strip() or "(none)" + default_tags = getattr(_cfg, 'DEFAULT_HASHTAGS', '').strip() or "(none)" update.message.reply_text( f"Hashtags? Choose an option or type custom (e.g., #ai #dev). Default: {default_tags}", reply_markup=_reply_kb(sess) @@ -1344,14 +1439,14 @@ def handle_text_input(update: Update, context: CallbackContext): if choice == "back": sess["wizard_step"] = "tone" update.message.reply_text( - f"Pick a tone (default {os.getenv('DEFAULT_TONE','concise')}).", + f"Pick a tone (default {getattr(_cfg, 'DEFAULT_TONE', 'concise')}).", reply_markup=_reply_kb(sess) ) return if choice == "generate now": topic = sess.get("topic", "") - n = int(sess.get("n", 6)) - tone = sess.get("tone") or os.getenv("DEFAULT_TONE", "concise").strip() + n = int(sess.get("n", getattr(_cfg, 'DEFAULT_THREAD_N', 6))) + tone = sess.get("tone") or getattr(_cfg, 'DEFAULT_TONE', 'concise').strip() hashtags = sess.get("hashtags", "") loading_msg = None try: @@ -1388,15 +1483,15 @@ def handle_text_input(update: Update, context: CallbackContext): update.message.reply_text(_build_preview(sess), reply_markup=_kb(sess)) return if choice == "use default": - sess["hashtags"] = os.getenv("DEFAULT_HASHTAGS", "").strip() + sess["hashtags"] = getattr(_cfg, 'DEFAULT_HASHTAGS', '').strip() elif choice in ("no tags", "skip"): sess["hashtags"] = "" else: sess["hashtags"] = text topic = sess.get("topic", "") - n = int(sess.get("n", 6)) - tone = sess.get("tone") or os.getenv("DEFAULT_TONE", "concise").strip() + n = int(sess.get("n", getattr(_cfg, 'DEFAULT_THREAD_N', 6))) + tone = sess.get("tone") or getattr(_cfg, 'DEFAULT_TONE', 'concise').strip() hashtags = sess.get("hashtags", "") loading_msg = None try: diff --git a/apps/Cortensor-XGenBot/src/config.py b/apps/Cortensor-XGenBot/src/config.py index 7302f11..ac98eb5 100644 --- a/apps/Cortensor-XGenBot/src/config.py +++ b/apps/Cortensor-XGenBot/src/config.py @@ -12,8 +12,8 @@ CORTENSOR_API_KEY = os.getenv('CORTENSOR_API_KEY') CORTENSOR_SESSION_ID = os.getenv('CORTENSOR_SESSION_ID') -MODEL_PROVIDER = os.getenv('MODEL_PROVIDER', 'deepseek') -MODEL_NAME = os.getenv('MODEL_NAME', 'deepseek-r1') +MODEL_PROVIDER = os.getenv('MODEL_PROVIDER', 'gpt-oss') +MODEL_NAME = os.getenv('MODEL_NAME', 'gpt-oss-20b') DEFAULT_TONE = os.getenv('DEFAULT_TONE', 'concise') DEFAULT_HASHTAGS = (os.getenv('DEFAULT_HASHTAGS') or '').strip() CORTENSOR_TLS_INSECURE = os.getenv('CORTENSOR_TLS_INSECURE', 'false').lower() in ('1','true','yes','on') @@ -62,5 +62,83 @@ _irrelevant_raw = (os.getenv('IRRELEVANT_WORDS') or '').replace('\n', ',') IRRELEVANT_WORDS = [w.strip().lower() for w in _irrelevant_raw.split(',') if w.strip()] +# ============================================ +# NEW CONFIGURABLE VALUES (moved from hardcoded) +# ============================================ + +# Available tones (comma-separated) +_tones_raw = os.getenv('AVAILABLE_TONES', 'concise,informative,persuasive,technical,conversational,authoritative') +AVAILABLE_TONES = [t.strip() for t in _tones_raw.split(',') if t.strip()] + +# Character limit presets for UI cycling (comma-separated) +_presets_raw = os.getenv('CHAR_LIMIT_PRESETS', '280,400,600,800,1000') +CHAR_LIMIT_PRESETS = [int(x.strip()) for x in _presets_raw.split(',') if x.strip().isdigit()] + +# Length target character counts +try: + LENGTH_SHORT_CHARS = int(os.getenv('LENGTH_SHORT_CHARS', '140')) +except Exception: + LENGTH_SHORT_CHARS = 140 + +try: + LENGTH_MEDIUM_CHARS = int(os.getenv('LENGTH_MEDIUM_CHARS', '200')) +except Exception: + LENGTH_MEDIUM_CHARS = 200 + +try: + LENGTH_LONG_CHARS = int(os.getenv('LENGTH_LONG_CHARS', '240')) +except Exception: + LENGTH_LONG_CHARS = 240 + +# Auto-length thresholds parsing +# Format: "4:240,8:200,12:180" means <=4 posts=240, <=8=200, <=12=180 +_auto_raw = os.getenv('AUTO_LENGTH_THRESHOLDS', '4:240,8:200,12:180') +AUTO_LENGTH_THRESHOLDS = [] +for pair in _auto_raw.split(','): + if ':' in pair: + parts = pair.split(':') + try: + AUTO_LENGTH_THRESHOLDS.append((int(parts[0].strip()), int(parts[1].strip()))) + except Exception: + pass +AUTO_LENGTH_THRESHOLDS.sort(key=lambda x: x[0]) + +try: + AUTO_LENGTH_FALLBACK_CHARS = int(os.getenv('AUTO_LENGTH_FALLBACK_CHARS', '160')) +except Exception: + AUTO_LENGTH_FALLBACK_CHARS = 160 + +# Default number of posts in thread +try: + DEFAULT_THREAD_N = int(os.getenv('DEFAULT_THREAD_N', '6')) +except Exception: + DEFAULT_THREAD_N = 6 + +# Default length mode +DEFAULT_LENGTH = os.getenv('DEFAULT_LENGTH', 'medium').strip().lower() + +# Thread posts limits +try: + MIN_THREAD_POSTS = int(os.getenv('MIN_THREAD_POSTS', '2')) +except Exception: + MIN_THREAD_POSTS = 2 + +try: + MAX_THREAD_POSTS = int(os.getenv('MAX_THREAD_POSTS', '25')) +except Exception: + MAX_THREAD_POSTS = 25 + +# Model identity for prompts +MODEL_IDENTITY = os.getenv('MODEL_IDENTITY', 'XGenBot AI').strip() + +# X/Twitter compose base URL +X_COMPOSE_URL = os.getenv('X_COMPOSE_URL', 'https://x.com/intent/tweet').strip() + +# Twitter fetch timeout +try: + TWITTER_FETCH_TIMEOUT = float(os.getenv('TWITTER_FETCH_TIMEOUT', '6.0')) +except Exception: + TWITTER_FETCH_TIMEOUT = 6.0 + if CORTENSOR_TLS_INSECURE: logger.getChild('config').warning('CORTENSOR_TLS_INSECURE is enabled. SSL certificate verification is DISABLED for Cortensor requests.') diff --git a/apps/Cortensor-XGenBot/src/db/storage.py b/apps/Cortensor-XGenBot/src/db/storage.py index e8c1dfb..1c01adc 100644 --- a/apps/Cortensor-XGenBot/src/db/storage.py +++ b/apps/Cortensor-XGenBot/src/db/storage.py @@ -84,12 +84,25 @@ def load_user_defaults(user_id: str) -> Dict[str, Any]: conn.close() -essential_defaults = { - "tone": os.getenv("DEFAULT_TONE", "concise"), - "length": "medium", - "n": 6, - "hashtags": (os.getenv("DEFAULT_HASHTAGS") or "").strip(), -} +# Import config for defaults (lazy to avoid circular imports) +def _get_config_defaults(): + try: + from ..config import DEFAULT_TONE, DEFAULT_LENGTH, DEFAULT_THREAD_N, DEFAULT_HASHTAGS + return { + "tone": DEFAULT_TONE or "concise", + "length": DEFAULT_LENGTH or "medium", + "n": DEFAULT_THREAD_N or 6, + "hashtags": (DEFAULT_HASHTAGS or "").strip(), + } + except Exception: + return { + "tone": os.getenv("DEFAULT_TONE", "concise"), + "length": os.getenv("DEFAULT_LENGTH", "medium"), + "n": int(os.getenv("DEFAULT_THREAD_N", "6")), + "hashtags": (os.getenv("DEFAULT_HASHTAGS") or "").strip(), + } + +essential_defaults = _get_config_defaults() def save_user_defaults(user_id: str, values: Dict[str, Any]) -> None: diff --git a/apps/Cortensor-XGenBot/src/link_fetch.py b/apps/Cortensor-XGenBot/src/link_fetch.py index 6c1df50..3b4bbd4 100644 --- a/apps/Cortensor-XGenBot/src/link_fetch.py +++ b/apps/Cortensor-XGenBot/src/link_fetch.py @@ -9,6 +9,13 @@ except Exception: # pragma: no cover - optional dep OAuth1 = None # type: ignore +# Import config for TWITTER_FETCH_TIMEOUT +try: + from . import config as _cfg + _FETCH_TIMEOUT = getattr(_cfg, 'TWITTER_FETCH_TIMEOUT', 6.0) +except Exception: + _FETCH_TIMEOUT = float(os.getenv('TWITTER_FETCH_TIMEOUT', '6.0')) + _X_STATUS_RE = re.compile(r"https?://(?:x|twitter)\.com/[^/]+/status/(\d+)") _X_URL_RE = re.compile(r"https?://(?:x|twitter)\.com/[^\s]+/status/\d+[^\s]*") @@ -16,7 +23,9 @@ def parse_x_status_id(url: str) -> Optional[str]: m = _X_STATUS_RE.search(url) return m.group(1) if m else None -def fetch_x_tweet_json(status_id: str, timeout: float = 6.0) -> Optional[Dict[str, Any]]: +def fetch_x_tweet_json(status_id: str, timeout: float = None) -> Optional[Dict[str, Any]]: + if timeout is None: + timeout = _FETCH_TIMEOUT # 1) Try official Twitter API v2 if credentials are present api_key = os.getenv("TWITTER_API_KEY") api_secret = os.getenv("TWITTER_API_SECRET") @@ -113,10 +122,12 @@ def fetch_x_tweet_json(status_id: str, timeout: float = 6.0) -> Optional[Dict[st except Exception: return None -def fetch_x_tweet_json_from_text(source_text: str, timeout: float = 6.0) -> Optional[Dict[str, Any]]: +def fetch_x_tweet_json_from_text(source_text: str, timeout: float = None) -> Optional[Dict[str, Any]]: """Try to resolve a tweet from any text containing a tweet URL. Attempts by ID, then by URL-param syndication. """ + if timeout is None: + timeout = _FETCH_TIMEOUT if not source_text: return None # Try by ID first diff --git a/apps/Cortensor-XGenBot/src/main.py b/apps/Cortensor-XGenBot/src/main.py index 3e6afeb..fe14692 100644 --- a/apps/Cortensor-XGenBot/src/main.py +++ b/apps/Cortensor-XGenBot/src/main.py @@ -19,10 +19,19 @@ def main(): config = import_module("src.config") bot_module = import_module("src.bot") db_module = import_module("src.db.storage") + validators = import_module("src.validators") except Exception: from . import config as config # type: ignore from . import bot as bot_module # type: ignore from .db import storage as db_module # type: ignore + from . import validators as validators # type: ignore + + # Run configuration validation on startup + try: + # Set strict=False to continue with warnings, strict=True to fail on errors + validators.run_startup_validation(strict=False) + except Exception as e: + logger.error(f"Config validation error: {e}") if not all([ getattr(config, "TELEGRAM_BOT_TOKEN", None), diff --git a/apps/Cortensor-XGenBot/src/rate_limiter.py b/apps/Cortensor-XGenBot/src/rate_limiter.py new file mode 100644 index 0000000..ab484ff --- /dev/null +++ b/apps/Cortensor-XGenBot/src/rate_limiter.py @@ -0,0 +1,188 @@ +""" +Rate limiter for XGenBot. +Prevents abuse by limiting requests per user per time window. +""" +import os +import time +import logging +from typing import Dict, Tuple +from functools import wraps + +logger = logging.getLogger(__name__) + +# Load rate limit settings from environment (with defaults) +def _get_int_env(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except (ValueError, TypeError): + return default + +DEFAULT_REQUESTS_PER_MINUTE = _get_int_env('RATE_LIMIT_PER_MINUTE', 10) +DEFAULT_REQUESTS_PER_HOUR = _get_int_env('RATE_LIMIT_PER_HOUR', 60) +DEFAULT_GENERATION_COOLDOWN = _get_int_env('GENERATION_COOLDOWN', 5) + + +class RateLimiter: + """ + Token bucket rate limiter with per-user tracking. + """ + + def __init__( + self, + requests_per_minute: int = DEFAULT_REQUESTS_PER_MINUTE, + requests_per_hour: int = DEFAULT_REQUESTS_PER_HOUR, + generation_cooldown: int = DEFAULT_GENERATION_COOLDOWN + ): + self.requests_per_minute = requests_per_minute + self.requests_per_hour = requests_per_hour + self.generation_cooldown = generation_cooldown + + # Track requests: {user_id: [(timestamp, action), ...]} + self._requests: Dict[str, list] = {} + # Track last generation time: {user_id: timestamp} + self._last_generation: Dict[str, float] = {} + # Cleanup old entries periodically + self._last_cleanup = time.time() + self._cleanup_interval = 300 # 5 minutes + + def _cleanup_old_entries(self): + """Remove entries older than 1 hour""" + now = time.time() + if now - self._last_cleanup < self._cleanup_interval: + return + + self._last_cleanup = now + cutoff = now - 3600 # 1 hour + + for uid in list(self._requests.keys()): + self._requests[uid] = [ + (ts, action) for ts, action in self._requests[uid] + if ts > cutoff + ] + if not self._requests[uid]: + del self._requests[uid] + + def _get_request_counts(self, user_id: str) -> Tuple[int, int]: + """Get request counts for last minute and last hour""" + now = time.time() + minute_ago = now - 60 + hour_ago = now - 3600 + + requests = self._requests.get(user_id, []) + minute_count = sum(1 for ts, _ in requests if ts > minute_ago) + hour_count = sum(1 for ts, _ in requests if ts > hour_ago) + + return minute_count, hour_count + + def check_rate_limit(self, user_id: str, action: str = "request") -> Tuple[bool, str]: + """ + Check if user is within rate limits. + + Returns: + Tuple of (is_allowed, error_message) + """ + self._cleanup_old_entries() + + minute_count, hour_count = self._get_request_counts(user_id) + + if minute_count >= self.requests_per_minute: + wait_time = 60 - (time.time() - min( + ts for ts, _ in self._requests.get(user_id, [(time.time(), "")]) + if ts > time.time() - 60 + )) + return False, f"Rate limit exceeded. Please wait {int(wait_time)}s before trying again." + + if hour_count >= self.requests_per_hour: + return False, "Hourly rate limit exceeded. Please try again later." + + return True, "" + + def check_generation_cooldown(self, user_id: str) -> Tuple[bool, str]: + """ + Check if user can generate (cooldown between generations). + + Returns: + Tuple of (is_allowed, error_message) + """ + now = time.time() + last_gen = self._last_generation.get(user_id, 0) + + if now - last_gen < self.generation_cooldown: + wait_time = self.generation_cooldown - (now - last_gen) + return False, f"Please wait {int(wait_time)}s before generating again." + + return True, "" + + def record_request(self, user_id: str, action: str = "request"): + """Record a request from user""" + now = time.time() + if user_id not in self._requests: + self._requests[user_id] = [] + self._requests[user_id].append((now, action)) + + def record_generation(self, user_id: str): + """Record a generation action from user""" + self._last_generation[user_id] = time.time() + self.record_request(user_id, "generation") + + def get_user_stats(self, user_id: str) -> Dict: + """Get rate limit stats for a user""" + minute_count, hour_count = self._get_request_counts(user_id) + last_gen = self._last_generation.get(user_id, 0) + cooldown_remaining = max(0, self.generation_cooldown - (time.time() - last_gen)) + + return { + "requests_last_minute": minute_count, + "requests_last_hour": hour_count, + "minute_limit": self.requests_per_minute, + "hour_limit": self.requests_per_hour, + "generation_cooldown_remaining": int(cooldown_remaining), + } + + +# Global rate limiter instance +_rate_limiter: RateLimiter = None + + +def get_rate_limiter() -> RateLimiter: + """Get or create the global rate limiter instance""" + global _rate_limiter + if _rate_limiter is None: + _rate_limiter = RateLimiter() + return _rate_limiter + + +def rate_limit_check(user_id: str, is_generation: bool = False) -> Tuple[bool, str]: + """ + Convenience function to check rate limits. + + Args: + user_id: The user ID to check + is_generation: If True, also check generation cooldown + + Returns: + Tuple of (is_allowed, error_message) + """ + limiter = get_rate_limiter() + + # Check general rate limit + allowed, msg = limiter.check_rate_limit(user_id) + if not allowed: + return allowed, msg + + # Check generation cooldown if applicable + if is_generation: + allowed, msg = limiter.check_generation_cooldown(user_id) + if not allowed: + return allowed, msg + + return True, "" + + +def record_user_action(user_id: str, is_generation: bool = False): + """Record a user action for rate limiting""" + limiter = get_rate_limiter() + if is_generation: + limiter.record_generation(user_id) + else: + limiter.record_request(user_id) diff --git a/apps/Cortensor-XGenBot/src/thread_gen.py b/apps/Cortensor-XGenBot/src/thread_gen.py index 12fe3a1..ceba327 100644 --- a/apps/Cortensor-XGenBot/src/thread_gen.py +++ b/apps/Cortensor-XGenBot/src/thread_gen.py @@ -35,21 +35,28 @@ def _finalize_line(text: str) -> str: def _length_target(length: str, n_posts: int) -> Tuple[int, str]: - length = (length or 'medium').lower() + """Get character target for given length mode. + + All values are now configurable via .env: + - LENGTH_SHORT_CHARS, LENGTH_MEDIUM_CHARS, LENGTH_LONG_CHARS + - AUTO_LENGTH_THRESHOLDS, AUTO_LENGTH_FALLBACK_CHARS + """ + length = (length or getattr(config, 'DEFAULT_LENGTH', 'medium')).lower() + if length == 'short': - return 140, 'short' + return getattr(config, 'LENGTH_SHORT_CHARS', 140), 'short' if length == 'long': - return 240, 'long' + return getattr(config, 'LENGTH_LONG_CHARS', 240), 'long' if length == 'auto': - # Auto scale: more posts -> slightly shorter lines - if n_posts <= 4: - return 240, 'auto' - if n_posts <= 8: - return 200, 'auto' - if n_posts <= 12: - return 180, 'auto' - return 160, 'auto' - return 200, 'medium' + # Auto scale: more posts -> slightly shorter lines (configurable thresholds) + thresholds = getattr(config, 'AUTO_LENGTH_THRESHOLDS', [(4, 240), (8, 200), (12, 180)]) + for threshold, chars in thresholds: + if n_posts <= threshold: + return chars, 'auto' + return getattr(config, 'AUTO_LENGTH_FALLBACK_CHARS', 160), 'auto' + + # Default: medium + return getattr(config, 'LENGTH_MEDIUM_CHARS', 200), 'medium' def _clean_line(s: str) -> str: @@ -144,9 +151,13 @@ def _extract_text(out) -> str: def generate_thread(topic: str, n_posts: int, tone: str | None, hashtags: str, instructions: str, length: str, offset: int = 0) -> List[str]: - n = max(2, min(25, int(n_posts or 6))) + min_posts = getattr(config, 'MIN_THREAD_POSTS', 2) + max_posts = getattr(config, 'MAX_THREAD_POSTS', 25) + default_n = getattr(config, 'DEFAULT_THREAD_N', 6) + n = max(min_posts, min(max_posts, int(n_posts or default_n))) target, _ = _length_target(length, n) tone = tone or config.DEFAULT_TONE + model_identity = getattr(config, 'MODEL_IDENTITY', 'XGenBot AI') role_pattern = (config.THREAD_ROLE_PATTERN or '') roles: list[str] = [] if role_pattern: @@ -164,7 +175,7 @@ def generate_thread(topic: str, n_posts: int, tone: str | None, hashtags: str, i numbering_clause = "Never prepend ratios like 1/5 or (1)." if config.THREAD_ENUM_FORMAT == 'none' else "Use light, natural sequencing only when it reads organically; avoid '1/5' style counters." prompt_parts = [ - "You are Meta Llama 3.1 8B Instruct Q4_K_M acting as a senior social strategist.", + f"You are {model_identity} acting as a senior social strategist.", "Goal: craft a native X/Twitter thread that feels insightful and concise.", "", "Thread requirements:", @@ -246,8 +257,9 @@ def format_thread_preview(posts: List[str]) -> List[str]: def generate_tweet(topic: str, tone: str | None, length: str, hashtags: str) -> str: target, _ = _length_target(length, 1) tone = tone or config.DEFAULT_TONE + model_identity = getattr(config, 'MODEL_IDENTITY', 'XGenBot AI') prompt_parts = [ - "You are Meta Llama 3.1 8B Instruct Q4_K_M acting as a senior X/Twitter copywriter.", + f"You are {model_identity} acting as a senior X/Twitter copywriter.", "Task: craft exactly one scroll-stopping tweet for the topic below.", "", "Tweet requirements:", @@ -288,11 +300,12 @@ def generate_tweet(topic: str, tone: str | None, length: str, hashtags: str) -> def generate_reply(context_text: str, tone: str | None, length: str, instructions: str) -> str: target, _ = _length_target(length, 1) tone = tone or config.DEFAULT_TONE + model_identity = getattr(config, 'MODEL_IDENTITY', 'XGenBot AI') guidance_section = '' if instructions: guidance_section = "Additional guidance:\n" + instructions.strip() prompt_parts = [ - "You are Meta Llama 3.1 8B Instruct Q4_K_M acting as a thoughtful X/Twitter responder.", + f"You are {model_identity} acting as a thoughtful X/Twitter responder.", "Task: write one native reply to the post below.", "", "Reply requirements:", diff --git a/apps/Cortensor-XGenBot/src/validators.py b/apps/Cortensor-XGenBot/src/validators.py new file mode 100644 index 0000000..8b4f20c --- /dev/null +++ b/apps/Cortensor-XGenBot/src/validators.py @@ -0,0 +1,200 @@ +""" +Configuration validators for XGenBot. +Validates .env settings on startup to catch misconfigurations early. +""" +import os +import logging +from typing import List, Tuple, Optional + +logger = logging.getLogger(__name__) + + +class ConfigValidationError(Exception): + """Raised when configuration validation fails""" + pass + + +def validate_required_env(name: str, value: Optional[str]) -> Tuple[bool, str]: + """Validate that a required environment variable is set""" + if not value or not value.strip(): + return False, f"Required config '{name}' is missing or empty" + return True, "" + + +def validate_positive_int(name: str, value: str, min_val: int = 1, max_val: int = None) -> Tuple[bool, str]: + """Validate that a value is a positive integer within range""" + try: + v = int(value) + if v < min_val: + return False, f"Config '{name}' must be >= {min_val}, got {v}" + if max_val and v > max_val: + return False, f"Config '{name}' must be <= {max_val}, got {v}" + return True, "" + except (ValueError, TypeError): + return False, f"Config '{name}' must be a valid integer, got '{value}'" + + +def validate_positive_float(name: str, value: str, min_val: float = 0.0) -> Tuple[bool, str]: + """Validate that a value is a positive float""" + try: + v = float(value) + if v < min_val: + return False, f"Config '{name}' must be >= {min_val}, got {v}" + return True, "" + except (ValueError, TypeError): + return False, f"Config '{name}' must be a valid number, got '{value}'" + + +def validate_url(name: str, value: str) -> Tuple[bool, str]: + """Validate that a value looks like a URL""" + if not value: + return False, f"Config '{name}' is missing" + if not (value.startswith('http://') or value.startswith('https://')): + return False, f"Config '{name}' must be a valid HTTP(S) URL, got '{value}'" + return True, "" + + +def validate_comma_list(name: str, value: str, min_items: int = 1) -> Tuple[bool, str]: + """Validate that a value is a non-empty comma-separated list""" + if not value: + return False, f"Config '{name}' is missing" + items = [x.strip() for x in value.split(',') if x.strip()] + if len(items) < min_items: + return False, f"Config '{name}' must have at least {min_items} items" + return True, "" + + +def validate_choice(name: str, value: str, choices: List[str]) -> Tuple[bool, str]: + """Validate that a value is one of allowed choices""" + if not value: + return False, f"Config '{name}' is missing" + if value.lower() not in [c.lower() for c in choices]: + return False, f"Config '{name}' must be one of {choices}, got '{value}'" + return True, "" + + +def validate_all_configs() -> List[str]: + """ + Validate all configuration settings. + Returns list of error messages (empty if all valid). + """ + errors = [] + + # Required configs + required = [ + ('TELEGRAM_BOT_TOKEN', os.getenv('TELEGRAM_BOT_TOKEN')), + ('CORTENSOR_API_URL', os.getenv('CORTENSOR_API_URL')), + ('CORTENSOR_API_KEY', os.getenv('CORTENSOR_API_KEY')), + ('CORTENSOR_SESSION_ID', os.getenv('CORTENSOR_SESSION_ID')), + ] + + for name, value in required: + ok, msg = validate_required_env(name, value) + if not ok: + errors.append(msg) + + # URL validation + api_url = os.getenv('CORTENSOR_API_URL', '') + if api_url: + ok, msg = validate_url('CORTENSOR_API_URL', api_url) + if not ok: + errors.append(msg) + + # Integer validations + int_configs = [ + ('CORTENSOR_TIMEOUT', os.getenv('CORTENSOR_TIMEOUT', '45'), 1, 600), + ('TWEET_CHAR_LIMIT', os.getenv('TWEET_CHAR_LIMIT', '280'), 100, 10000), + ('PREVIEW_CHAR_LIMIT', os.getenv('PREVIEW_CHAR_LIMIT', '3900'), 500, 10000), + ('THREAD_CONTINUE_BATCH', os.getenv('THREAD_CONTINUE_BATCH', '3'), 1, 25), + ('DEFAULT_THREAD_N', os.getenv('DEFAULT_THREAD_N', '6'), 2, 25), + ('MIN_THREAD_POSTS', os.getenv('MIN_THREAD_POSTS', '2'), 1, 10), + ('MAX_THREAD_POSTS', os.getenv('MAX_THREAD_POSTS', '25'), 5, 100), + ('LENGTH_SHORT_CHARS', os.getenv('LENGTH_SHORT_CHARS', '140'), 50, 500), + ('LENGTH_MEDIUM_CHARS', os.getenv('LENGTH_MEDIUM_CHARS', '200'), 100, 1000), + ('LENGTH_LONG_CHARS', os.getenv('LENGTH_LONG_CHARS', '240'), 100, 1500), + ] + + for name, value, min_v, max_v in int_configs: + ok, msg = validate_positive_int(name, value, min_v, max_v) + if not ok: + errors.append(msg) + + # Float validations + float_configs = [ + ('TWITTER_FETCH_TIMEOUT', os.getenv('TWITTER_FETCH_TIMEOUT', '6.0'), 1.0), + ] + + for name, value, min_v in float_configs: + ok, msg = validate_positive_float(name, value, min_v) + if not ok: + errors.append(msg) + + # Choice validations + prompt_type = os.getenv('PROMPT_TYPE', '0') + ok, msg = validate_choice('PROMPT_TYPE', prompt_type, ['0', '1']) + if not ok: + errors.append(msg) + + default_length = os.getenv('DEFAULT_LENGTH', 'medium') + ok, msg = validate_choice('DEFAULT_LENGTH', default_length, ['short', 'medium', 'long', 'auto']) + if not ok: + errors.append(msg) + + thread_enum = os.getenv('THREAD_ENUM_FORMAT', 'fraction') + ok, msg = validate_choice('THREAD_ENUM_FORMAT', thread_enum, ['none', 'ofx', 'fraction']) + if not ok: + errors.append(msg) + + # List validations + tones = os.getenv('AVAILABLE_TONES', 'concise,informative') + ok, msg = validate_comma_list('AVAILABLE_TONES', tones, 1) + if not ok: + errors.append(msg) + + char_presets = os.getenv('CHAR_LIMIT_PRESETS', '280,400,600') + ok, msg = validate_comma_list('CHAR_LIMIT_PRESETS', char_presets, 1) + if not ok: + errors.append(msg) + + # Logical validations + min_posts = int(os.getenv('MIN_THREAD_POSTS', '2')) + max_posts = int(os.getenv('MAX_THREAD_POSTS', '25')) + default_n = int(os.getenv('DEFAULT_THREAD_N', '6')) + + if min_posts > max_posts: + errors.append(f"MIN_THREAD_POSTS ({min_posts}) cannot be greater than MAX_THREAD_POSTS ({max_posts})") + + if not (min_posts <= default_n <= max_posts): + errors.append(f"DEFAULT_THREAD_N ({default_n}) must be between MIN ({min_posts}) and MAX ({max_posts})") + + return errors + + +def run_startup_validation(strict: bool = False) -> bool: + """ + Run config validation on startup. + + Args: + strict: If True, raise exception on errors. If False, log warnings. + + Returns: + True if all validations passed, False otherwise. + """ + logger.info("Running configuration validation...") + errors = validate_all_configs() + + if not errors: + logger.info("✅ All configuration validations passed") + return True + + for err in errors: + if strict: + logger.error(f"❌ Config error: {err}") + else: + logger.warning(f"⚠️ Config warning: {err}") + + if strict: + raise ConfigValidationError(f"Configuration validation failed with {len(errors)} error(s)") + + logger.warning(f"⚠️ {len(errors)} configuration warning(s) found. Bot may not work correctly.") + return False