diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f59a73..8f4b22b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,9 +7,55 @@ on: branches: [main] jobs: + lint-backend: + name: Backend Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync + working-directory: backend + + - name: Run ruff + run: uv run ruff check openmlr/ tests/ + working-directory: backend + + lint-frontend: + name: Frontend Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm install + working-directory: frontend + + - name: Run ESLint + run: pnpm lint + working-directory: frontend + test-backend: name: Backend Tests runs-on: ubuntu-latest + needs: lint-backend steps: - uses: actions/checkout@v4 @@ -32,6 +78,7 @@ jobs: test-frontend: name: Frontend Tests runs-on: ubuntu-latest + needs: lint-frontend steps: - uses: actions/checkout@v4 diff --git a/Makefile b/Makefile index 48114d7..e277b68 100644 --- a/Makefile +++ b/Makefile @@ -97,6 +97,30 @@ check-backend: ## Verify backend loads without errors check-frontend: ## Type-check the frontend (tsc --noEmit) cd $(FRONTEND) && npx tsc --noEmit +# ─── Linting ───────────────────────────────────────────── + +.PHONY: lint +lint: lint-backend lint-frontend ## Run all linters + +.PHONY: lint-backend +lint-backend: ## Lint backend with ruff + cd $(BACKEND) && uv run ruff check openmlr/ tests/ + +.PHONY: lint-frontend +lint-frontend: ## Lint frontend with ESLint + cd $(FRONTEND) && pnpm lint + +.PHONY: lint-fix +lint-fix: lint-fix-backend lint-fix-frontend ## Auto-fix linting issues + +.PHONY: lint-fix-backend +lint-fix-backend: ## Auto-fix backend linting issues + cd $(BACKEND) && uv run ruff check openmlr/ tests/ --fix + +.PHONY: lint-fix-frontend +lint-fix-frontend: ## Auto-fix frontend linting issues + cd $(FRONTEND) && pnpm lint:fix + # ─── Testing ───────────────────────────────────────────── .PHONY: test diff --git a/backend/openmlr/agent/context.py b/backend/openmlr/agent/context.py index f4a28f7..bae7c9d 100644 --- a/backend/openmlr/agent/context.py +++ b/backend/openmlr/agent/context.py @@ -1,8 +1,9 @@ """ContextManager — message history, compaction, undo, token tracking.""" from dataclasses import dataclass, field -from .types import Message, ToolCall + from ..config import AgentConfig, get_model_max_tokens +from .types import Message, ToolCall def estimate_tokens(text: str) -> int: diff --git a/backend/openmlr/agent/doom_loop.py b/backend/openmlr/agent/doom_loop.py index a9dff9c..3886c17 100644 --- a/backend/openmlr/agent/doom_loop.py +++ b/backend/openmlr/agent/doom_loop.py @@ -2,6 +2,7 @@ import hashlib import json + from .types import Message diff --git a/backend/openmlr/agent/llm.py b/backend/openmlr/agent/llm.py index 5877326..03bfc86 100644 --- a/backend/openmlr/agent/llm.py +++ b/backend/openmlr/agent/llm.py @@ -1,18 +1,19 @@ """LLM Abstraction — multi-provider support (OpenAI, Anthropic, OpenRouter, litellm).""" -import os -import json import asyncio -from typing import AsyncGenerator, Optional -from .types import LLMResult, ToolCall, ToolSpec +import json +import os +from collections.abc import AsyncGenerator + from ..config import AgentConfig +from .types import LLMResult, ToolCall class LLMProvider: """Handles LLM calls across multiple providers with streaming and retry.""" @staticmethod - def _get_api_key(model_name: str) -> Optional[str]: + def _get_api_key(model_name: str) -> str | None: mn = model_name.lower() if mn.startswith("openai/"): return os.environ.get("OPENAI_API_KEY") @@ -37,9 +38,9 @@ def _normalize_model(model_name: str) -> str: if model_name.startswith(prefix): return model_name[len(prefix):] return model_name - + @staticmethod - def _get_base_url(model_name: str) -> Optional[str]: + def _get_base_url(model_name: str) -> str | None: """Get the base URL for local/custom OpenAI-compatible APIs.""" mn = model_name.lower() if mn.startswith("local/"): @@ -59,7 +60,7 @@ def _get_base_url(model_name: str) -> Optional[str]: return "https://opencode.ai/zen/go/v1" # Anthropic format return "https://opencode.ai/zen/go/v1" # OpenAI-compatible format return None - + @staticmethod def _is_opencode_go_anthropic_format(model_name: str) -> bool: """Check if OpenCode Go model uses Anthropic API format.""" @@ -75,7 +76,7 @@ def _is_anthropic_model(model_name: str) -> bool: """True only for direct Anthropic API calls (anthropic/ prefix). OpenRouter-routed Claude models use the OpenAI-compatible path.""" return model_name.lower().startswith("anthropic/") - + @staticmethod def _uses_anthropic_format(model_name: str) -> bool: """Check if model uses Anthropic message format (native Anthropic or OpenCode Go Anthropic models).""" @@ -89,7 +90,7 @@ def _uses_anthropic_format(model_name: str) -> bool: async def generate( messages: list[dict], config: AgentConfig, - tools: Optional[list[dict]] = None, + tools: list[dict] | None = None, ) -> LLMResult: return await LLMProvider._call_with_retry(messages, config, tools) @@ -97,7 +98,7 @@ async def generate( async def generate_stream( messages: list[dict], config: AgentConfig, - tools: Optional[list[dict]] = None, + tools: list[dict] | None = None, ) -> AsyncGenerator[str | ToolCall | dict, None]: async for chunk in LLMProvider._stream_with_retry(messages, config, tools): yield chunk @@ -106,7 +107,7 @@ async def generate_stream( async def generate_title( messages: list[dict], config: AgentConfig, - ) -> Optional[str]: + ) -> str | None: title_prompt = ( "Based on the conversation, generate a short title " "(max 6 words). Return ONLY the title, nothing else." @@ -143,7 +144,7 @@ def _is_retryable(e: Exception) -> bool: async def _call_with_retry( messages: list[dict], config: AgentConfig, - tools: Optional[list[dict]] = None, + tools: list[dict] | None = None, max_retries: int = 3, ) -> LLMResult: last_error = None @@ -165,7 +166,7 @@ async def _call_with_retry( async def _stream_with_retry( messages: list[dict], config: AgentConfig, - tools: Optional[list[dict]] = None, + tools: list[dict] | None = None, ) -> AsyncGenerator[str | ToolCall | dict, None]: last_error = None for attempt in range(3): @@ -189,22 +190,23 @@ async def _stream_with_retry( @staticmethod def _openai_client(config: AgentConfig): - from openai import AsyncOpenAI import logging + + from openai import AsyncOpenAI logger = logging.getLogger(__name__) - + api_key = LLMProvider._get_api_key(config.model_name) base_url = LLMProvider._get_base_url(config.model_name) - + logger.debug(f"[LLM] Model: {config.model_name}, Base URL: {base_url}, API key set: {bool(api_key)}") - + kwargs = {"api_key": api_key} if base_url: kwargs["base_url"] = base_url return AsyncOpenAI(**kwargs) @staticmethod - def _openai_tool_param(tools: Optional[list[dict]]) -> Optional[list[dict]]: + def _openai_tool_param(tools: list[dict] | None) -> list[dict] | None: """Convert tool specs to OpenAI tools param. Handles both raw and pre-wrapped.""" if not tools: return None @@ -222,7 +224,7 @@ def _openai_tool_param(tools: Optional[list[dict]]) -> Optional[list[dict]]: async def _call_openai( messages: list[dict], config: AgentConfig, - tools: Optional[list[dict]], + tools: list[dict] | None, ) -> LLMResult: client = LLMProvider._openai_client(config) model = LLMProvider._normalize_model(config.model_name) @@ -263,7 +265,7 @@ async def _call_openai( async def _stream_openai( messages: list[dict], config: AgentConfig, - tools: Optional[list[dict]], + tools: list[dict] | None, ) -> AsyncGenerator[str | ToolCall | dict, None]: client = LLMProvider._openai_client(config) model = LLMProvider._normalize_model(config.model_name) @@ -335,7 +337,7 @@ async def _stream_openai( # ── Anthropic ───────────────────────────────────────── @staticmethod - def _anthropic_tool_param(tools: Optional[list[dict]]) -> Optional[list[dict]]: + def _anthropic_tool_param(tools: list[dict] | None) -> list[dict] | None: """Convert tool specs to Anthropic format.""" if not tools: return None @@ -392,7 +394,7 @@ def _to_anthropic_messages(messages: list[dict]) -> tuple[str, list[dict]]: def _anthropic_client(config: AgentConfig): """Create Anthropic client with appropriate settings for native or OpenCode Go.""" from anthropic import AsyncAnthropic - + mn = config.model_name.lower() if mn.startswith("opencode-go/"): # OpenCode Go uses Anthropic format but different endpoint/key @@ -407,7 +409,7 @@ def _anthropic_client(config: AgentConfig): async def _call_anthropic( messages: list[dict], config: AgentConfig, - tools: Optional[list[dict]], + tools: list[dict] | None, ) -> LLMResult: model = LLMProvider._normalize_model(config.model_name) client = LLMProvider._anthropic_client(config) @@ -449,7 +451,7 @@ async def _call_anthropic( async def _stream_anthropic( messages: list[dict], config: AgentConfig, - tools: Optional[list[dict]], + tools: list[dict] | None, ) -> AsyncGenerator[str | ToolCall | dict, None]: model = LLMProvider._normalize_model(config.model_name) client = LLMProvider._anthropic_client(config) diff --git a/backend/openmlr/agent/loop.py b/backend/openmlr/agent/loop.py index deaf92e..b7fc698 100644 --- a/backend/openmlr/agent/loop.py +++ b/backend/openmlr/agent/loop.py @@ -1,16 +1,14 @@ """Agentic loop — the core turn-processing engine with tool execution.""" -import json import asyncio +import json import traceback -from typing import Optional -from .types import AgentEvent, Message, ToolCall, ToolSpec, Submission, OpType, LLMResult -from .session import Session -from .context import ContextManager -from .llm import LLMProvider -from .doom_loop import detect_doom_loop from ..config import AgentConfig +from .doom_loop import detect_doom_loop +from .llm import LLMProvider +from .session import Session +from .types import AgentEvent, LLMResult, Message, OpType, Submission, ToolCall async def submission_loop(session: Session, tool_router) -> None: @@ -167,7 +165,7 @@ async def _run_agent(session: Session, tool_router, user_message: str, mode: str return_exceptions=True, ) - for tc, res in zip(auto_approve, results): + for tc, res in zip(auto_approve, results, strict=False): if isinstance(res, Exception): output = f"Error: {str(res)}" success = False @@ -233,7 +231,7 @@ async def _stream_llm_call( session: Session, messages: list[dict], tools: list[dict], -) -> Optional[LLMResult]: +) -> LLMResult | None: """Execute a streaming LLM call, emitting chunks to SSE.""" content_buffer = "" tool_calls: list[ToolCall] = [] @@ -278,7 +276,7 @@ async def _non_stream_llm_call( session: Session, messages: list[dict], tools: list[dict], -) -> Optional[LLMResult]: +) -> LLMResult | None: """Execute a non-streaming LLM call.""" result = await LLMProvider.generate(messages, session.config, tools) diff --git a/backend/openmlr/agent/prompts.py b/backend/openmlr/agent/prompts.py index 6765f09..6004fcd 100644 --- a/backend/openmlr/agent/prompts.py +++ b/backend/openmlr/agent/prompts.py @@ -1,16 +1,14 @@ """System prompt builder — loads Jinja2 YAML template and renders.""" import os -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from typing import Optional import yaml from jinja2 import Template -from .types import ToolSpec from ..config import AgentConfig - +from .types import ToolSpec PROMPT_DIR = Path(__file__).parent.parent.parent / "configs" / "prompts" COMPACT_PROMPT = ( @@ -27,7 +25,7 @@ def build_system_prompt( mode: str = "general", username: str = "user", sandbox_info: str = "none", - config: Optional[AgentConfig] = None, + config: AgentConfig | None = None, ) -> str: """Build the full system prompt from YAML template.""" template_path = PROMPT_DIR / "system_prompt.yaml" @@ -41,7 +39,7 @@ def build_system_prompt( template_str = _fallback_prompt() cwd = os.getcwd() - now = datetime.now(timezone.utc) + now = datetime.now(UTC) template = Template(template_str) prompt = template.render( diff --git a/backend/openmlr/agent/session.py b/backend/openmlr/agent/session.py index a8aff54..4c7672b 100644 --- a/backend/openmlr/agent/session.py +++ b/backend/openmlr/agent/session.py @@ -1,12 +1,13 @@ """Session — per-conversation state container.""" import asyncio +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Callable, Awaitable, Optional +from typing import Any from ..config import AgentConfig from .context import ContextManager -from .types import AgentEvent, Submission, OpType +from .types import AgentEvent @dataclass @@ -19,19 +20,19 @@ class Session: submission_queue: asyncio.Queue = field(default_factory=asyncio.Queue) # Conversation reference (for database operations in tools) - conversation_id: Optional[int] = None + conversation_id: int | None = None # Cancellation _cancelled: asyncio.Event = field(default_factory=asyncio.Event) # Approval flow - pending_approval: Optional[dict] = None + pending_approval: dict | None = None # Question/answer flow (ask_user tool) - pending_answers: Optional[Any] = None + pending_answers: Any | None = None # Sandbox reference - sandbox: Optional[Any] = None + sandbox: Any | None = None # Turn counter (for title generation etc.) turn_count: int = 0 diff --git a/backend/openmlr/agent/types.py b/backend/openmlr/agent/types.py index 877f993..c0964a8 100644 --- a/backend/openmlr/agent/types.py +++ b/backend/openmlr/agent/types.py @@ -1,8 +1,9 @@ """Agent core types — AgentEvent, Message, ToolSpec, ToolCall.""" -from dataclasses import dataclass, field -from typing import Any, Callable, Awaitable, Optional +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from enum import Enum +from typing import Any @dataclass @@ -19,8 +20,8 @@ class ToolSpec: name: str description: str parameters: dict[str, Any] # JSON Schema - handler: Optional[Callable[..., Awaitable[tuple[str, bool]]]] = None - needs_approval: Optional[Callable[..., bool]] = None + handler: Callable[..., Awaitable[tuple[str, bool]]] | None = None + needs_approval: Callable[..., bool] | None = None @dataclass @@ -28,16 +29,16 @@ class Message: """A message in the conversation context.""" role: str # "system", "user", "assistant", "tool" content: str - tool_calls: Optional[list[ToolCall]] = None - tool_call_id: Optional[str] = None - name: Optional[str] = None + tool_calls: list[ToolCall] | None = None + tool_call_id: str | None = None + name: str | None = None @dataclass(kw_only=True) class AgentEvent: """Event emitted by the agent loop for SSE streaming.""" event_type: str - data: Optional[dict[str, Any]] = None + data: dict[str, Any] | None = None def to_sse(self) -> str: import json @@ -67,4 +68,4 @@ class LLMResult: content: str tool_calls: list[ToolCall] finish_reason: str - usage: Optional[dict] = None + usage: dict | None = None diff --git a/backend/openmlr/app.py b/backend/openmlr/app.py index 98e3818..d034841 100644 --- a/backend/openmlr/app.py +++ b/backend/openmlr/app.py @@ -1,19 +1,19 @@ """FastAPI application — OpenMLR backend entry point.""" import os -from pathlib import Path from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles from .config import load_config -from .services.event_bus import EventBus -from .services.session_manager import SessionManager from .db.engine import engine from .db.models import Base +from .services.event_bus import EventBus +from .services.session_manager import SessionManager FRONTEND_DIST = Path(__file__).parent.parent.parent / "frontend" / "dist" @@ -23,7 +23,7 @@ async def lifespan(app: FastAPI): """Startup: create tables & shared state. Shutdown: teardown sessions.""" import logging logger = logging.getLogger("openmlr.app") - + async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) logger.info("Database tables created") @@ -35,13 +35,13 @@ async def lifespan(app: FastAPI): app.state.config = config app.state.event_bus = event_bus app.state.session_manager = session_manager - + # Start Redis event bridge for cross-worker communication (background jobs) await event_bus.start_redis_bridge() logger.info("Redis event bridge started") yield - + # Cleanup await event_bus.stop_redis_bridge() for conv_id in list(session_manager.sessions.keys()): @@ -71,8 +71,8 @@ async def lifespan(app: FastAPI): # ── API routers ────────────────────────────────────────── from .auth.router import router as auth_router from .routes.agent import router as agent_router -from .routes.settings import router as settings_router from .routes.health import router as health_router +from .routes.settings import router as settings_router app.include_router(auth_router) app.include_router(agent_router) diff --git a/backend/openmlr/auth/router.py b/backend/openmlr/auth/router.py index edd2138..d310e70 100644 --- a/backend/openmlr/auth/router.py +++ b/backend/openmlr/auth/router.py @@ -1,14 +1,14 @@ """Authentication router — login, register, user info.""" -from sqlalchemy import select, func +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from fastapi import APIRouter, Depends, HTTPException, status -from ..db.models import User +from ..auth.security import create_access_token, hash_password, verify_password from ..db.engine import get_db -from ..auth.security import hash_password, verify_password, create_access_token -from ..models import UserRegister, UserLogin, TokenResponse +from ..db.models import User from ..dependencies import get_current_user +from ..models import TokenResponse, UserLogin, UserRegister router = APIRouter(prefix="/api/auth", tags=["auth"]) diff --git a/backend/openmlr/auth/security.py b/backend/openmlr/auth/security.py index 91791e8..2942710 100644 --- a/backend/openmlr/auth/security.py +++ b/backend/openmlr/auth/security.py @@ -1,12 +1,12 @@ """Authentication security — password hashing (bcrypt) and JWT tokens.""" +import logging import os import secrets -import logging -from datetime import datetime, timezone, timedelta +from datetime import UTC, datetime, timedelta import bcrypt -from jose import jwt, JWTError +from jose import JWTError, jwt logger = logging.getLogger(__name__) @@ -39,7 +39,7 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: def create_access_token(user_id: int, username: str) -> str: - expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + expire = datetime.now(UTC) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) payload = { "sub": str(user_id), "username": username, diff --git a/backend/openmlr/celery_app.py b/backend/openmlr/celery_app.py index c10ac2b..91b43ab 100644 --- a/backend/openmlr/celery_app.py +++ b/backend/openmlr/celery_app.py @@ -1,6 +1,7 @@ """Celery application configuration for background agent jobs.""" import os + from celery import Celery from dotenv import load_dotenv @@ -23,23 +24,23 @@ result_serializer="json", timezone="UTC", enable_utc=True, - + # Task execution settings task_acks_late=True, # Acknowledge after completion for reliability task_reject_on_worker_lost=True, worker_prefetch_multiplier=1, # Don't prefetch, process one at a time - + # Result backend settings result_expires=3600, # Results expire after 1 hour - + # Worker settings worker_concurrency=4, # Number of concurrent workers - + # Task routing (optional - can route different tasks to different queues) task_routes={ "openmlr.tasks.agent_tasks.process_agent_message": {"queue": "agent"}, }, - + # Default queue task_default_queue="default", ) diff --git a/backend/openmlr/config.py b/backend/openmlr/config.py index cf6662a..30d47dd 100644 --- a/backend/openmlr/config.py +++ b/backend/openmlr/config.py @@ -1,12 +1,12 @@ """Configuration loading with layered priority: env vars > project config > defaults.""" import os -import yaml -from pathlib import Path from dataclasses import dataclass, field -from typing import Optional +from pathlib import Path +import yaml from dotenv import load_dotenv + load_dotenv() @@ -70,7 +70,7 @@ def detect_cheap_model() -> str: return "openrouter/openai/gpt-4o-mini" -def load_config(config_path: Optional[Path] = None) -> AgentConfig: +def load_config(config_path: Path | None = None) -> AgentConfig: """Load agent configuration with layered priority.""" config = AgentConfig() diff --git a/backend/openmlr/db/create_tables.py b/backend/openmlr/db/create_tables.py index 98c6b28..600d7ea 100644 --- a/backend/openmlr/db/create_tables.py +++ b/backend/openmlr/db/create_tables.py @@ -1,7 +1,8 @@ """Create (or recreate) all database tables from SQLAlchemy models.""" -import sys import asyncio +import sys + from .engine import engine from .models import Base diff --git a/backend/openmlr/db/engine.py b/backend/openmlr/db/engine.py index 829d84f..8c3e995 100644 --- a/backend/openmlr/db/engine.py +++ b/backend/openmlr/db/engine.py @@ -2,8 +2,9 @@ import os from contextvars import ContextVar + from dotenv import load_dotenv -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine load_dotenv() @@ -29,7 +30,7 @@ def get_worker_session() -> async_sessionmaker: """Get or create an engine/session factory for the current worker context. - + This ensures Celery workers get their own engine instance to avoid conflicts with asyncpg connection pool across event loops. """ @@ -37,9 +38,9 @@ def get_worker_session() -> async_sessionmaker: if eng is None: # Create a new engine for this worker context eng = create_async_engine( - DATABASE_URL, - echo=False, - pool_size=5, + DATABASE_URL, + echo=False, + pool_size=5, max_overflow=10, pool_pre_ping=True, # Verify connections before use ) diff --git a/backend/openmlr/db/migrations/env.py b/backend/openmlr/db/migrations/env.py index 1053460..c05476b 100644 --- a/backend/openmlr/db/migrations/env.py +++ b/backend/openmlr/db/migrations/env.py @@ -1,14 +1,13 @@ """Alembic migration environment.""" -import os import asyncio +import os from logging.config import fileConfig +from alembic import context from sqlalchemy import pool from sqlalchemy.ext.asyncio import create_async_engine -from alembic import context - # Import all models so Alembic can detect them from openmlr.db.models import Base diff --git a/backend/openmlr/db/models.py b/backend/openmlr/db/models.py index 9bd77d7..bb0cd93 100644 --- a/backend/openmlr/db/models.py +++ b/backend/openmlr/db/models.py @@ -1,13 +1,20 @@ """SQLAlchemy ORM models for all tables.""" import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime + from sqlalchemy import ( - Column, Integer, String, Text, Boolean, DateTime, ForeignKey, - JSON, Float, + JSON, + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + Text, ) -from sqlalchemy.orm import DeclarativeBase, relationship from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.orm import DeclarativeBase, relationship class Base(DeclarativeBase): @@ -15,7 +22,7 @@ class Base(DeclarativeBase): def _utcnow(): - return datetime.now(timezone.utc) + return datetime.now(UTC) class User(Base): diff --git a/backend/openmlr/db/operations.py b/backend/openmlr/db/operations.py index 37be93d..663a440 100644 --- a/backend/openmlr/db/operations.py +++ b/backend/openmlr/db/operations.py @@ -1,14 +1,19 @@ """Database CRUD operations for conversations and messages.""" -from datetime import datetime, timezone -from sqlalchemy import select, delete, update, func +from datetime import UTC, datetime + +from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession + from .models import ( - Conversation, Message, ResearchCorpus, WritingProject, SandboxConfig, - ConversationTask, ConversationResource, AgentJob, UserSetting, + AgentJob, + Conversation, + ConversationResource, + ConversationTask, + Message, + UserSetting, ) - # ---- Conversations ---- async def create_conversation( @@ -218,7 +223,7 @@ async def upsert_conversation_tasks( await db.execute( delete(ConversationTask).where(ConversationTask.conversation_id == conv_id) ) - + # Insert new tasks new_tasks = [] for idx, task_data in enumerate(tasks): @@ -231,7 +236,7 @@ async def upsert_conversation_tasks( ) db.add(task) new_tasks.append(task) - + await db.commit() return new_tasks @@ -300,12 +305,12 @@ async def upsert_conversation_resources( ) -> list[ConversationResource]: """Replace all resources for a conversation with the new list.""" import uuid as uuid_mod - + # Delete existing resources await db.execute( delete(ConversationResource).where(ConversationResource.conversation_id == conv_id) ) - + # Insert new resources new_resources = [] for res_data in resources: @@ -319,7 +324,7 @@ async def upsert_conversation_resources( ) db.add(resource) new_resources.append(resource) - + await db.commit() return new_resources @@ -446,18 +451,18 @@ async def update_job_status( job = await get_agent_job(db, job_id) if not job: return False - + job.status = status if error: job.error = error if worker_id: job.worker_id = worker_id - + if status == "running" and not job.started_at: - job.started_at = datetime.now(timezone.utc) + job.started_at = datetime.now(UTC) elif status in ("completed", "failed", "cancelled"): - job.completed_at = datetime.now(timezone.utc) - + job.completed_at = datetime.now(UTC) + await db.commit() return True diff --git a/backend/openmlr/dependencies.py b/backend/openmlr/dependencies.py index 4399501..a584066 100644 --- a/backend/openmlr/dependencies.py +++ b/backend/openmlr/dependencies.py @@ -1,14 +1,14 @@ """FastAPI dependencies — auth, database sessions, config.""" +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from .auth.security import decode_access_token +from .config import AgentConfig, load_config from .db.engine import get_db as _get_db from .db.models import User -from .auth.security import decode_access_token -from .config import load_config, AgentConfig security = HTTPBearer(auto_error=False) diff --git a/backend/openmlr/models.py b/backend/openmlr/models.py index 2e5e24e..d178eea 100644 --- a/backend/openmlr/models.py +++ b/backend/openmlr/models.py @@ -1,16 +1,16 @@ """Pydantic models for API requests and responses.""" -from pydantic import BaseModel, Field -from typing import Optional, Any from datetime import datetime +from typing import Any +from pydantic import BaseModel, Field # ---- Auth ---- class UserRegister(BaseModel): username: str = Field(min_length=3, max_length=50) password: str = Field(min_length=6, max_length=128) - display_name: Optional[str] = None + display_name: str | None = None class UserLogin(BaseModel): username: str @@ -24,22 +24,22 @@ class TokenResponse(BaseModel): class UserInfo(BaseModel): id: int username: str - display_name: Optional[str] + display_name: str | None is_active: bool created_at: datetime # ---- Conversations ---- class ConversationCreate(BaseModel): - title: Optional[str] = "New conversation" - model: Optional[str] = None - mode: Optional[str] = "general" # "research", "writing", "coding", "general" + title: str | None = "New conversation" + model: str | None = None + mode: str | None = "general" # "research", "writing", "coding", "general" class ConversationResponse(BaseModel): id: int uuid: str title: str - model: Optional[str] + model: str | None mode: str user_message_count: int created_at: datetime @@ -49,7 +49,7 @@ class MessageResponse(BaseModel): id: int role: str content: str - metadata: Optional[dict] = None + metadata: dict | None = None created_at: datetime class ConversationDetail(BaseModel): @@ -60,7 +60,7 @@ class ConversationDetail(BaseModel): class MessageSend(BaseModel): message: str - mode: Optional[str] = None # plan, research, write — per-message mode override + mode: str | None = None # plan, research, write — per-message mode override class ApprovalRequest(BaseModel): approvals: dict[str, bool] # tool_call_id -> approved @@ -71,14 +71,14 @@ class SettingUpdate(BaseModel): value: Any class ProviderConfig(BaseModel): - openai_api_key: Optional[str] = None - anthropic_api_key: Optional[str] = None - openrouter_api_key: Optional[str] = None - brave_api_key: Optional[str] = None - github_token: Optional[str] = None - semantic_scholar_api_key: Optional[str] = None - modal_token_id: Optional[str] = None - modal_token_secret: Optional[str] = None + openai_api_key: str | None = None + anthropic_api_key: str | None = None + openrouter_api_key: str | None = None + brave_api_key: str | None = None + github_token: str | None = None + semantic_scholar_api_key: str | None = None + modal_token_id: str | None = None + modal_token_secret: str | None = None # ---- Model Management ---- @@ -89,4 +89,4 @@ class ModelSwitch(BaseModel): class AgentEvent(BaseModel): event_type: str - data: Optional[dict] = None + data: dict | None = None diff --git a/backend/openmlr/routes/agent.py b/backend/openmlr/routes/agent.py index f395d4b..0e25942 100644 --- a/backend/openmlr/routes/agent.py +++ b/backend/openmlr/routes/agent.py @@ -2,18 +2,19 @@ import asyncio import logging + from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger("openmlr.routes") +from ..agent.types import AgentEvent +from ..db import operations as ops from ..db.engine import get_db from ..db.models import User -from ..db import operations as ops from ..dependencies import get_current_user -from ..models import ConversationCreate, MessageSend, ApprovalRequest, ModelSwitch -from ..agent.types import AgentEvent +from ..models import ApprovalRequest, ConversationCreate, MessageSend, ModelSwitch router = APIRouter(prefix="/api", tags=["agent"]) @@ -48,10 +49,10 @@ async def _stream(): while True: try: event = await asyncio.wait_for(queue.get(), timeout=25) - et = event.get("event_type", "?") if isinstance(event, dict) else "?" + event.get("event_type", "?") if isinstance(event, dict) else "?" payload = f"data: {json.dumps(event)}\n\n" yield payload - except asyncio.TimeoutError: + except TimeoutError: yield ":ping\n\n" except asyncio.CancelledError: pass @@ -123,18 +124,18 @@ async def get_conversation( ): conv = await _get_conv_or_404(db, uuid, user.id) msgs = await ops.get_messages(db, conv.id) - + # Re-generate title if still "New conversation" and has messages if conv.title == "New conversation" and msgs: msg_dicts = [_msg_dict(m) for m in msgs] asyncio.create_task( _auto_title(_sm(request), _bus(request), db, conv.id, conv.uuid, msg_dicts) ) - + # Fetch persisted tasks and resources tasks = await ops.get_conversation_tasks(db, conv.id) resources = await ops.get_conversation_resources(db, conv.id) - + return { "conversation": _conv_dict(conv), "messages": [_msg_dict(m) for m in msgs], @@ -154,7 +155,7 @@ async def delete_conversation( db: AsyncSession = Depends(get_db), ): conv = await _get_conv_or_404(db, uuid, user.id) - + # Cancel any running background jobs for this conversation try: from ..services.job_manager import get_job_manager @@ -164,15 +165,15 @@ async def delete_conversation( await job_manager.cancel_job(db, job_info["job_id"]) except Exception: pass - + # Cancel in-process session (cancels agent loop, pending questions, sandbox) await _sm(request).remove_session(conv.id) - + # Broadcast interrupted so frontend stops any spinners for this conversation await _bus(request).broadcast( AgentEvent(event_type="interrupted", data={"conversation_uuid": conv.uuid}) ) - + await ops.delete_conversation(db, conv.id) return {"ok": True} @@ -187,7 +188,7 @@ async def switch_conversation( conv = await _get_conv_or_404(db, uuid, user.id) sm = _sm(request) msg_dicts = await _load_messages(db, conv.id) - + # Get user's default model if conversation has none user_agent_settings = await ops.get_user_agent_settings(db, user.id) effective_model = conv.model or user_agent_settings.get("default_model") @@ -214,8 +215,8 @@ async def send_message( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - from ..services.job_manager import get_job_manager, USE_BACKGROUND_JOBS - + from ..services.job_manager import USE_BACKGROUND_JOBS, get_job_manager + sm = _sm(request) event_bus = _bus(request) job_manager = get_job_manager() @@ -223,7 +224,7 @@ async def send_message( # Load user's agent settings (default_model, research_model, etc.) user_agent_settings = await ops.get_user_agent_settings(db, user.id) user_default_model = user_agent_settings.get("default_model") - + if not sm.current_conversation_id: # Create conversation with user's default model conv = await ops.create_conversation(db, user.id, model=user_default_model) @@ -233,13 +234,13 @@ async def send_message( if not conv: raise HTTPException(status_code=400, detail="No active conversation") - + # If conversation has no model, use user's default effective_model = conv.model or user_default_model # Title generation after 1st and 3rd messages user_count = (conv.user_message_count or 0) + 1 - + # If background jobs are enabled, use Celery if USE_BACKGROUND_JOBS: job = await job_manager.create_job( @@ -251,14 +252,14 @@ async def send_message( model=effective_model, uuid=conv.uuid, ) - + # Title generation (still async in web process for now) if user_count in (1, 3): msg_dicts = await _load_messages(db, conv.id) asyncio.create_task( _auto_title(sm, event_bus, db, conv.id, conv.uuid, msg_dicts) ) - + return {"ok": True, "job_id": job.job_id if job else None, "background": True} # Synchronous processing (original flow) @@ -408,7 +409,7 @@ async def interrupt( # Also try to revoke active Celery tasks for this conversation try: - from ..services.job_manager import get_job_manager, USE_BACKGROUND_JOBS + from ..services.job_manager import USE_BACKGROUND_JOBS, get_job_manager if USE_BACKGROUND_JOBS: job_manager = get_job_manager() active_jobs = await job_manager.get_active_jobs(db, conv_id) @@ -471,10 +472,10 @@ async def switch_model( if active: active.session.update_model(body.model) await ops.update_conversation_model(db, active.conversation_id, body.model) - + # Persist as the user's sticky default model await ops.set_user_setting(db, user.id, "agent", "default_model", body.model) - + await _bus(request).broadcast( AgentEvent(event_type="model_info", data={"model": body.model}) ) @@ -538,16 +539,16 @@ async def _auto_title(sm, event_bus, db, conv_id, uuid, messages): """Generate a title for the conversation using LLM.""" from ..agent.llm import LLMProvider from ..config import AgentConfig, detect_cheap_model - + try: # Try session-based generation first title = await sm.generate_title(conv_id, messages) - + # Fallback: generate without session using cheap model if not title and messages: config = AgentConfig(title_model=detect_cheap_model()) title = await LLMProvider.generate_title(messages, config) - + if title: await ops.update_conversation_title(db, conv_id, title) await event_bus.broadcast( diff --git a/backend/openmlr/routes/health.py b/backend/openmlr/routes/health.py index 4edca2f..0ba4fc2 100644 --- a/backend/openmlr/routes/health.py +++ b/backend/openmlr/routes/health.py @@ -1,7 +1,8 @@ """Health check endpoint for deployment platforms.""" +from datetime import UTC, datetime + from fastapi import APIRouter -from datetime import datetime, timezone router = APIRouter(tags=["health"]) @@ -14,7 +15,7 @@ async def health(): return { "status": "ok", "version": VERSION, - "timestamp": datetime.now(timezone.utc).isoformat(), + "timestamp": datetime.now(UTC).isoformat(), } diff --git a/backend/openmlr/routes/settings.py b/backend/openmlr/routes/settings.py index 5d8ad41..9217862 100644 --- a/backend/openmlr/routes/settings.py +++ b/backend/openmlr/routes/settings.py @@ -1,13 +1,14 @@ """Settings routes — user settings, provider config, model management.""" import os + import httpx from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession +from ..db import operations as ops from ..db.engine import get_db from ..db.models import User -from ..db import operations as ops from ..dependencies import get_current_user router = APIRouter(prefix="/api", tags=["settings"]) @@ -76,7 +77,7 @@ async def delete_setting( db: AsyncSession = Depends(get_db), ): await ops.delete_user_setting(db, user.id, category, key) - + # If a provider key was deleted, check if the current model still has a valid provider if category == "providers": provider_map = { @@ -102,7 +103,7 @@ async def delete_setting( env_key = env_key_map.get(key) if env_key and env_key in os.environ: del os.environ[env_key] - + return {"ok": True} @@ -198,7 +199,7 @@ async def get_status( # User's explicitly selected model, or fall back to auto-detected user_model = agent_settings.get("default_model") or None effective_model = user_model or config.model_name - + # Only need onboarding if no providers are configured at all # (i.e., auto-detection also failed to find anything useful) has_any_provider = any([ @@ -214,7 +215,7 @@ async def get_status( user_providers = await ops.get_all_settings(db, user.id, category="providers") prov = user_providers.get("providers", {}) has_any_provider = any(v for v in prov.values() if v) - + return { "model": effective_model, "research_model": agent_settings.get("research_model") or config.research_model, @@ -259,7 +260,7 @@ async def list_models(): {"id": "openrouter/google/gemini-2.5-pro", "name": "OR Gemini 2.5 Pro", "provider": "openrouter"}, {"id": "openrouter/google/gemini-2.5-flash", "name": "OR Gemini 2.5 Flash", "provider": "openrouter"}, ] - + # Add OpenCode Go models opencode_go_models = [ {"id": "opencode-go/glm-5.1", "name": "GLM-5.1", "provider": "opencode-go"}, @@ -276,7 +277,7 @@ async def list_models(): {"id": "opencode-go/qwen3.5-plus", "name": "Qwen3.5 Plus", "provider": "opencode-go"}, ] models.extend(opencode_go_models) - + # Add local model placeholders if configured if os.environ.get("OLLAMA_API_BASE"): ollama_model = os.environ.get("OLLAMA_MODEL", "llama3.1") @@ -285,10 +286,10 @@ async def list_models(): for m in ["llama3.1", "llama3.2", "qwen2.5-coder", "codellama", "deepseek-coder-v2", "mistral"]: if m != ollama_model: models.append({"id": f"ollama/{m}", "name": f"Ollama: {m}", "provider": "ollama"}) - + if os.environ.get("LMSTUDIO_API_BASE"): models.append({"id": "lmstudio/default", "name": "LM Studio (default)", "provider": "lmstudio"}) - + return {"models": models} @@ -304,7 +305,7 @@ async def save_config( # Whitelist of allowed environment variables to set ALLOWED_ENV_KEYS = { "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", + "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY", "OPENCODE_GO_API_KEY", "BRAVE_API_KEY", @@ -313,7 +314,7 @@ async def save_config( "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", } - + body = await request.json() for key, value in body.items(): diff --git a/backend/openmlr/sandbox/interface.py b/backend/openmlr/sandbox/interface.py index 68f47a2..ef61c28 100644 --- a/backend/openmlr/sandbox/interface.py +++ b/backend/openmlr/sandbox/interface.py @@ -2,7 +2,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Optional @dataclass @@ -11,7 +10,7 @@ class EnvironmentInfo: os: str = "unknown" python_version: str = "unknown" gpu_available: bool = False - gpu_info: Optional[str] = None + gpu_info: str | None = None installed_packages: list[str] = field(default_factory=list) available_disk_gb: float = 0.0 available_ram_gb: float = 0.0 diff --git a/backend/openmlr/sandbox/local.py b/backend/openmlr/sandbox/local.py index 4914251..89143bc 100644 --- a/backend/openmlr/sandbox/local.py +++ b/backend/openmlr/sandbox/local.py @@ -1,12 +1,13 @@ """Local sandbox — direct filesystem and shell execution.""" -import os import asyncio +import os import platform import shutil import time from pathlib import Path -from .interface import SandboxInterface, EnvironmentInfo, ExecutionResult + +from .interface import EnvironmentInfo, ExecutionResult, SandboxInterface class LocalSandbox(SandboxInterface): @@ -49,7 +50,7 @@ async def execute(self, command: str, timeout: int = 120) -> ExecutionResult: exit_code=proc.returncode, duration_seconds=duration, ) - except asyncio.TimeoutError: + except TimeoutError: return ExecutionResult( output=f"Command timed out after {timeout}s", success=False, diff --git a/backend/openmlr/sandbox/manager.py b/backend/openmlr/sandbox/manager.py index 8baac7c..dc054e7 100644 --- a/backend/openmlr/sandbox/manager.py +++ b/backend/openmlr/sandbox/manager.py @@ -1,20 +1,20 @@ """SandboxManager — lifecycle management and provider selection.""" -from typing import Optional + from .interface import SandboxInterface from .local import LocalSandbox -from .ssh import SSHSandbox from .modal_sandbox import ModalSandbox +from .ssh import SSHSandbox class SandboxManager: """Manages sandbox lifecycle: create, switch, destroy.""" def __init__(self): - self._active: Optional[SandboxInterface] = None + self._active: SandboxInterface | None = None self.active_type: str = "none" - def get_active(self) -> Optional[SandboxInterface]: + def get_active(self) -> SandboxInterface | None: return self._active async def create(self, provider: str, config: dict = None) -> SandboxInterface: diff --git a/backend/openmlr/sandbox/modal_sandbox.py b/backend/openmlr/sandbox/modal_sandbox.py index 037ad65..7713a44 100644 --- a/backend/openmlr/sandbox/modal_sandbox.py +++ b/backend/openmlr/sandbox/modal_sandbox.py @@ -2,8 +2,8 @@ import asyncio import time -from typing import Optional -from .interface import SandboxInterface, EnvironmentInfo, ExecutionResult + +from .interface import EnvironmentInfo, ExecutionResult, SandboxInterface class ModalSandbox(SandboxInterface): @@ -13,7 +13,7 @@ def __init__(self): self._sandbox = None self._app = None self.image_name: str = "python:3.12" - self.gpu: Optional[str] = None + self.gpu: str | None = None self.packages: list[str] = [] async def create(self, config: dict) -> "ModalSandbox": @@ -93,7 +93,7 @@ def _do_exec(): exit_code=exit_code, duration_seconds=time.monotonic() - start, ) - except asyncio.TimeoutError: + except TimeoutError: return ExecutionResult( output=f"Command timed out after {timeout}s", success=False, @@ -117,7 +117,7 @@ async def read_file(self, path: str) -> str: async def write_file(self, path: str, content: str) -> bool: self._ensure_active() # Use heredoc for safe content transfer - escaped = content.replace("'", "'\\''") + content.replace("'", "'\\''") result = await self.execute( f"mkdir -p $(dirname '{path}') && cat > '{path}' << 'OPEN_MLR_EOF'\n{content}\nOPEN_MLR_EOF", timeout=10, @@ -141,7 +141,7 @@ async def list_files(self, path: str = ".") -> list[str]: result = await self.execute(f"ls -1 '{path}'", timeout=5) if not result.success: return [] - return [l for l in result.output.strip().split("\n") if l] + return [line for line in result.output.strip().split("\n") if line] async def probe_environment(self) -> EnvironmentInfo: info = EnvironmentInfo() diff --git a/backend/openmlr/sandbox/ssh.py b/backend/openmlr/sandbox/ssh.py index 6f4a482..aa0c63d 100644 --- a/backend/openmlr/sandbox/ssh.py +++ b/backend/openmlr/sandbox/ssh.py @@ -2,8 +2,8 @@ import asyncio import time -from typing import Optional -from .interface import SandboxInterface, EnvironmentInfo, ExecutionResult + +from .interface import EnvironmentInfo, ExecutionResult, SandboxInterface class SSHSandbox(SandboxInterface): @@ -15,8 +15,8 @@ def __init__(self): self.host: str = "" self.port: int = 22 self.username: str = "" - self.key_path: Optional[str] = None - self.password: Optional[str] = None + self.key_path: str | None = None + self.password: str | None = None self.workdir: str = "~" async def create(self, config: dict) -> "SSHSandbox": diff --git a/backend/openmlr/services/event_bus.py b/backend/openmlr/services/event_bus.py index f763136..db39c7c 100644 --- a/backend/openmlr/services/event_bus.py +++ b/backend/openmlr/services/event_bus.py @@ -4,7 +4,8 @@ import json import logging import os -from typing import AsyncGenerator, Optional +from collections.abc import AsyncGenerator + from ..agent.types import AgentEvent logger = logging.getLogger("openmlr.sse") @@ -15,14 +16,14 @@ class EventBus: """Manages SSE event broadcasting to multiple clients. - + When USE_REDIS_PUBSUB is enabled, also forwards events to Redis for cross-worker communication. """ def __init__(self): self._subscribers: list[asyncio.Queue] = [] - self._redis_bridge_task: Optional[asyncio.Task] = None + self._redis_bridge_task: asyncio.Task | None = None def subscribe(self) -> asyncio.Queue: queue = asyncio.Queue(maxsize=1000) @@ -59,7 +60,7 @@ async def broadcast(self, event: AgentEvent | dict) -> None: for q in dead: self.unsubscribe(q) - + # Also publish to Redis if enabled if USE_REDIS: try: @@ -80,10 +81,10 @@ async def start_redis_bridge(self) -> None: if not USE_REDIS: logger.info("USE_REDIS_PUBSUB not enabled, skipping Redis bridge") return - + if self._redis_bridge_task is not None: return - + async def _listen(): from .redis_pubsub import subscribe_events logger.info("Redis subscription loop started") @@ -97,7 +98,7 @@ async def _listen(): pass except Exception as e: logger.warning(f"Redis bridge error: {e}") - + self._redis_bridge_task = asyncio.create_task(_listen()) logger.info("Redis event bridge started") @@ -124,7 +125,7 @@ async def sse_generator(queue: asyncio.Queue) -> AsyncGenerator[str, None]: event = await asyncio.wait_for(queue.get(), timeout=30) payload = f"data: {json.dumps(event)}\n\n" yield payload - except asyncio.TimeoutError: + except TimeoutError: yield ":ping\n\n" except asyncio.CancelledError: pass diff --git a/backend/openmlr/services/job_manager.py b/backend/openmlr/services/job_manager.py index de40543..0a2a729 100644 --- a/backend/openmlr/services/job_manager.py +++ b/backend/openmlr/services/job_manager.py @@ -1,8 +1,8 @@ """Job manager — handles background job creation and status tracking.""" -import os import logging -from typing import Optional +import os + from sqlalchemy.ext.asyncio import AsyncSession from ..db import operations as ops @@ -16,10 +16,10 @@ class JobManager: """Manages background agent job creation and tracking.""" - + def __init__(self): self._celery_app = None - + @property def celery_app(self): """Lazy load Celery app to avoid import issues.""" @@ -27,7 +27,7 @@ def celery_app(self): from ..celery_app import celery_app self._celery_app = celery_app return self._celery_app - + async def create_job( self, db: AsyncSession, @@ -37,15 +37,15 @@ async def create_job( mode: str = None, model: str = None, uuid: str = None, - ) -> Optional[AgentJob]: + ) -> AgentJob | None: """ Create a new background job for processing an agent message. - + Returns the job record, or None if background jobs are disabled. """ if not USE_BACKGROUND_JOBS: return None - + # Create job record in database job = await ops.create_agent_job( db=db, @@ -54,7 +54,7 @@ async def create_job( message=message, mode=mode, ) - + # Enqueue Celery task from ..tasks.agent_tasks import process_agent_message process_agent_message.delay( @@ -66,20 +66,20 @@ async def create_job( model=model, uuid=uuid, ) - + logger.info(f"Created background job {job.job_id} for conversation {conversation_id}") return job - + async def get_job_status( self, db: AsyncSession, job_id: str, - ) -> Optional[dict]: + ) -> dict | None: """Get the current status of a job.""" job = await ops.get_agent_job(db, job_id) if not job: return None - + return { "job_id": job.job_id, "status": job.status, @@ -88,7 +88,7 @@ async def get_job_status( "started_at": job.started_at.isoformat() if job.started_at else None, "completed_at": job.completed_at.isoformat() if job.completed_at else None, } - + async def get_active_jobs( self, db: AsyncSession, @@ -105,7 +105,7 @@ async def get_active_jobs( } for job in jobs ] - + async def cancel_job( self, db: AsyncSession, @@ -115,23 +115,23 @@ async def cancel_job( job = await ops.get_agent_job(db, job_id) if not job: return False - + if job.status == "queued": # Can cancel queued jobs await ops.update_job_status(db, job_id, "cancelled") - + # Revoke the Celery task if self.celery_app: self.celery_app.control.revoke(job_id, terminate=False) - + return True - + # Can't cancel running/completed jobs easily return False # Global instance -_job_manager: Optional[JobManager] = None +_job_manager: JobManager | None = None def get_job_manager() -> JobManager: diff --git a/backend/openmlr/services/redis_pubsub.py b/backend/openmlr/services/redis_pubsub.py index 452830d..6883114 100644 --- a/backend/openmlr/services/redis_pubsub.py +++ b/backend/openmlr/services/redis_pubsub.py @@ -1,12 +1,14 @@ """Redis pub/sub for cross-worker event broadcasting.""" -import os -import json import asyncio +import json import logging +import os +from collections.abc import AsyncIterator from contextvars import ContextVar -from typing import Optional, AsyncIterator + import redis.asyncio as redis + from ..agent.types import AgentEvent logger = logging.getLogger("openmlr.services.redis_pubsub") @@ -15,7 +17,7 @@ CHANNEL_NAME = "openmlr:events" # Context-local Redis client to handle different event loops (Celery workers) -_redis_client: ContextVar[Optional[redis.Redis]] = ContextVar("redis_client", default=None) +_redis_client: ContextVar[redis.Redis | None] = ContextVar("redis_client", default=None) async def get_redis() -> redis.Redis: @@ -46,7 +48,7 @@ async def subscribe_events() -> AsyncIterator[AgentEvent]: client = await get_redis() pubsub = client.pubsub() await pubsub.subscribe(CHANNEL_NAME) - + try: async for message in pubsub.listen(): if message["type"] == "message": @@ -66,17 +68,17 @@ async def subscribe_events() -> AsyncIterator[AgentEvent]: class RedisEventBridge: """ Bridge between Redis pub/sub and local event bus. - + This class subscribes to Redis events and forwards them to local SSE subscribers, allowing background Celery workers to communicate with connected browser clients. """ - + def __init__(self): - self._task: Optional[asyncio.Task] = None + self._task: asyncio.Task | None = None self._local_subscribers: list[asyncio.Queue] = [] self._running = False - + async def start(self) -> None: """Start listening to Redis events.""" if self._running: @@ -84,7 +86,7 @@ async def start(self) -> None: self._running = True self._task = asyncio.create_task(self._listen_loop()) logger.info("Redis event bridge started") - + async def stop(self) -> None: """Stop listening to Redis events.""" self._running = False @@ -95,18 +97,18 @@ async def stop(self) -> None: except asyncio.CancelledError: pass logger.info("Redis event bridge stopped") - + def subscribe(self) -> asyncio.Queue: """Subscribe to receive events. Returns a queue that receives events.""" queue: asyncio.Queue = asyncio.Queue() self._local_subscribers.append(queue) return queue - + def unsubscribe(self, queue: asyncio.Queue) -> None: """Unsubscribe from events.""" if queue in self._local_subscribers: self._local_subscribers.remove(queue) - + async def _listen_loop(self) -> None: """Internal loop that listens to Redis and forwards events.""" while self._running: @@ -127,7 +129,7 @@ async def _listen_loop(self) -> None: # Global instance -_redis_bridge: Optional[RedisEventBridge] = None +_redis_bridge: RedisEventBridge | None = None async def get_redis_bridge() -> RedisEventBridge: diff --git a/backend/openmlr/services/session_manager.py b/backend/openmlr/services/session_manager.py index d4bfe44..01b04de 100644 --- a/backend/openmlr/services/session_manager.py +++ b/backend/openmlr/services/session_manager.py @@ -1,15 +1,13 @@ """Session manager — manages per-conversation agent sessions.""" -import asyncio -from typing import Optional -from ..agent.session import Session -from ..agent.types import AgentEvent -from ..agent.loop import run_agent_turn from ..agent.llm import LLMProvider +from ..agent.loop import run_agent_turn from ..agent.prompts import build_system_prompt +from ..agent.session import Session +from ..agent.types import AgentEvent from ..config import AgentConfig -from ..tools.registry import create_tool_router, ToolRouter from ..sandbox.manager import SandboxManager +from ..tools.registry import ToolRouter, create_tool_router from .event_bus import EventBus @@ -39,14 +37,14 @@ def __init__(self, event_bus: EventBus, default_config: AgentConfig): self.sessions: dict[int, ActiveSession] = {} self.event_bus = event_bus self.default_config = default_config - self.current_conversation_id: Optional[int] = None + self.current_conversation_id: int | None = None self._is_processing: bool = False self._message_queues: dict[int, list[str]] = {} - def get_session(self, conversation_id: int) -> Optional[ActiveSession]: + def get_session(self, conversation_id: int) -> ActiveSession | None: return self.sessions.get(conversation_id) - def get_current_session(self) -> Optional[ActiveSession]: + def get_current_session(self) -> ActiveSession | None: if self.current_conversation_id: return self.sessions.get(self.current_conversation_id) return None @@ -55,7 +53,7 @@ async def get_or_create_session( self, conversation_id: int, uuid: str, - model: Optional[str] = None, + model: str | None = None, mode: str = "general", existing_messages: list[dict] = None, username: str = "user", @@ -168,7 +166,7 @@ async def generate_title( self, conversation_id: int, messages: list[dict], - ) -> Optional[str]: + ) -> str | None: active = self.sessions.get(conversation_id) if not active: return None diff --git a/backend/openmlr/tasks/agent_tasks.py b/backend/openmlr/tasks/agent_tasks.py index 2885c08..92ac255 100644 --- a/backend/openmlr/tasks/agent_tasks.py +++ b/backend/openmlr/tasks/agent_tasks.py @@ -2,13 +2,12 @@ import asyncio import logging -from datetime import datetime, timezone +from ..agent.types import AgentEvent from ..celery_app import celery_app -from ..db.engine import get_worker_session from ..db import operations as ops +from ..db.engine import get_worker_session from ..services.redis_pubsub import publish_event -from ..agent.types import AgentEvent logger = logging.getLogger("openmlr.tasks") @@ -26,7 +25,7 @@ def process_agent_message( ): """ Background task to process an agent message. - + This task: 1. Updates job status to "running" 2. Creates/gets the agent session @@ -36,11 +35,11 @@ def process_agent_message( """ worker_id = self.request.id logger.info(f"Worker {worker_id} starting job {job_id} for conversation {conversation_id}") - + # Run the async agent processing in an event loop loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: loop.run_until_complete( _async_process_message( @@ -73,59 +72,59 @@ async def _async_process_message( worker_id: str, ): """Async implementation of message processing.""" - from ..config import load_config - from ..agent.session import Session from ..agent.loop import run_agent_turn from ..agent.prompts import build_system_prompt - from ..tools.registry import create_tool_router + from ..agent.session import Session + from ..config import load_config from ..sandbox.manager import SandboxManager - + from ..tools.registry import create_tool_router + # Get worker-specific session factory to avoid event loop conflicts worker_session = get_worker_session() - + # Update job status to running async with worker_session() as db: await ops.update_job_status(db, job_id, "running", worker_id=worker_id) - + # Load existing messages for context messages = await ops.get_messages(db, conversation_id) existing_messages = [ {"role": m.role, "content": m.content} for m in messages ] - + # Increment user message count await ops.increment_user_message_count(db, conversation_id) - + # Add user message to database await ops.add_message(db, conversation_id, "user", message) - + # Broadcast that we're processing await publish_event(AgentEvent( event_type="status", data={"status": "thinking...", "job_id": job_id}, )) - + # Create agent session config = load_config() if model: config.model_name = model - + session = Session(config=config, conversation_id=conversation_id) sandbox_manager = SandboxManager() tool_router = create_tool_router(sandbox_manager) - + # Build and set system prompt session.context_manager.system_prompt = build_system_prompt( tool_specs=tool_router.get_raw_specs(), mode=mode or "general", username="user", ) - + # Load existing messages into context for msg in existing_messages: session.context_manager.add_message(msg) - + # Wire event broadcasting to Redis pub/sub async def _broadcast(event: AgentEvent): # Add job_id to events for client filtering @@ -134,7 +133,7 @@ async def _broadcast(event: AgentEvent): event.data["job_id"] = job_id event.data["conversation_uuid"] = uuid await publish_event(event) - + # Persist assistant messages and tool outputs if event.event_type == "assistant_message" and event.data.get("content"): async with worker_session() as db: @@ -146,9 +145,9 @@ async def _broadcast(event: AgentEvent): "tool_call_id": event.data.get("tool_call_id"), "success": event.data.get("success"), }) - + session.on_event(_broadcast) - + # Start a background task that polls Redis for an interrupt signal # and cancels the session when found. async def _poll_interrupt(): @@ -171,28 +170,28 @@ async def _poll_interrupt(): try: # Run the agent turn await run_agent_turn(session, tool_router, message, mode=mode) - + # Mark job as completed async with worker_session() as db: await ops.update_job_status(db, job_id, "completed") - + # Broadcast completion await publish_event(AgentEvent( event_type="job_complete", data={"job_id": job_id, "conversation_uuid": uuid, "status": "completed"}, )) - + except Exception as e: logger.exception(f"Agent processing failed for job {job_id}: {e}") async with worker_session() as db: await ops.update_job_status(db, job_id, "failed", error=str(e)) - + await publish_event(AgentEvent( event_type="job_complete", data={"job_id": job_id, "conversation_uuid": uuid, "status": "failed", "error": str(e)}, )) raise - + finally: # Stop the interrupt polling task interrupt_task.cancel() @@ -206,7 +205,7 @@ async def _poll_interrupt(): await sandbox_manager.destroy() except Exception: pass - + # Clear any lingering interrupt key try: from ..services.redis_pubsub import clear_interrupt diff --git a/backend/openmlr/tools/ask_user.py b/backend/openmlr/tools/ask_user.py index 139b3c9..26254cf 100644 --- a/backend/openmlr/tools/ask_user.py +++ b/backend/openmlr/tools/ask_user.py @@ -6,7 +6,8 @@ """ import asyncio -from ..agent.types import ToolSpec, AgentEvent + +from ..agent.types import AgentEvent, ToolSpec def create_ask_user_tool() -> ToolSpec: @@ -99,8 +100,9 @@ async def _handle_ask_user( # Try Redis-based answer relay first (works with background jobs) try: - from ..services.redis_pubsub import wait_for_answers import os + + from ..services.redis_pubsub import wait_for_answers if os.environ.get("USE_BACKGROUND_JOBS", "").lower() in ("true", "1", "yes"): answers = await wait_for_answers(session.conversation_id, timeout=300) except Exception: @@ -113,7 +115,7 @@ async def _handle_ask_user( try: answers = await asyncio.wait_for(answer_future, timeout=300) - except asyncio.TimeoutError: + except TimeoutError: session.pending_answers = None return "User did not answer within 5 minutes.", False diff --git a/backend/openmlr/tools/github.py b/backend/openmlr/tools/github.py index 6a9a177..fbf0952 100644 --- a/backend/openmlr/tools/github.py +++ b/backend/openmlr/tools/github.py @@ -1,9 +1,10 @@ """GitHub tools — code search, repo listing, file reading.""" import os + import httpx -from ..agent.types import ToolSpec +from ..agent.types import ToolSpec GITHUB_API = "https://api.github.com" diff --git a/backend/openmlr/tools/local.py b/backend/openmlr/tools/local.py index 847e671..ea4a8a9 100644 --- a/backend/openmlr/tools/local.py +++ b/backend/openmlr/tools/local.py @@ -4,10 +4,11 @@ read/write/edit operate on the host filesystem (for project files). """ -import os import asyncio import logging +import os from pathlib import Path + from ..agent.types import ToolSpec logger = logging.getLogger(__name__) @@ -27,7 +28,7 @@ def _validate_path(path: Path) -> tuple[Path, str | None]: """Validate path is within allowed workspace. Returns (resolved_path, error_or_none).""" try: resolved = path.resolve() - + # If WORKSPACE_ROOT is set, enforce it if WORKSPACE_ROOT: workspace = Path(WORKSPACE_ROOT).resolve() @@ -47,7 +48,7 @@ def _validate_path(path: Path) -> tuple[Path, str | None]: for prefix in dangerous_prefixes: if str(resolved).startswith(prefix): return resolved, f"Access denied: {resolved} is in a protected system directory" - + return resolved, None except Exception as e: return path, f"Path validation error: {e}" @@ -198,7 +199,7 @@ async def _docker_exec(command: str, timeout: int, host_cwd: str, workdir: str = output = f"Exit code: {proc.returncode}\n{output}" return output, success - except asyncio.TimeoutError: + except TimeoutError: return f"Command timed out after {timeout}s", False except Exception as e: return f"Docker exec error: {str(e)}", False @@ -229,7 +230,7 @@ async def _direct_exec(command: str, timeout: int, cwd: str) -> tuple[str, bool] if not success: output = f"Exit code: {proc.returncode}\n{output}" return output, success - except asyncio.TimeoutError: + except TimeoutError: return f"Timed out after {timeout}s", False except Exception as e: return f"Error: {str(e)}", False @@ -255,7 +256,7 @@ async def _handle_read(path: str, offset: int = 1, limit: int = 2000, **kwargs) if not target.exists(): return f"File not found: {target}", False - with open(target, "r", encoding="utf-8", errors="replace") as f: + with open(target, encoding="utf-8", errors="replace") as f: all_lines = f.readlines() start = max(0, offset - 1) @@ -276,22 +277,22 @@ async def _handle_write(path: str = "", content: str = "", **kwargs) -> tuple[st path = kwargs.get("p", kwargs.get("file", kwargs.get("filepath", ""))) if not content: content = kwargs.get("c", kwargs.get("text", kwargs.get("data", ""))) - + if not path: return "Error: 'path' argument is required.", False if not content: return "Error: 'content' argument is required.", False - + try: target = Path(path).expanduser() if not target.is_absolute(): target = Path.cwd() / target - + # Security: Validate path is within allowed workspace target, error = _validate_path(target) if error: return error, False - + target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") return f"Wrote {len(content)} chars to {target}", True @@ -304,12 +305,12 @@ async def _handle_edit(path: str, old_string: str, new_string: str, replace_all: target = Path(path).expanduser() if not target.is_absolute(): target = Path.cwd() / target - + # Security: Validate path is within allowed workspace target, error = _validate_path(target) if error: return error, False - + if not target.exists(): return f"File not found: {target}", False diff --git a/backend/openmlr/tools/mcp.py b/backend/openmlr/tools/mcp.py index 4423515..ee5f005 100644 --- a/backend/openmlr/tools/mcp.py +++ b/backend/openmlr/tools/mcp.py @@ -2,8 +2,6 @@ import os import re -from typing import Optional -from ..agent.types import ToolSpec def substitute_env_vars(text: str) -> str: @@ -35,7 +33,7 @@ def process_mcp_config(config: dict) -> dict: async def connect_mcp_servers( mcp_configs: dict, tool_router, - blocklist: Optional[set[str]] = None, + blocklist: set[str] | None = None, ) -> int: """ Connect to configured MCP servers and register their tools. diff --git a/backend/openmlr/tools/papers.py b/backend/openmlr/tools/papers.py index 9109581..d158de6 100644 --- a/backend/openmlr/tools/papers.py +++ b/backend/openmlr/tools/papers.py @@ -6,8 +6,9 @@ import os import re + import httpx -from typing import Optional + from ..agent.types import ToolSpec OPENALEX_API = "https://api.openalex.org" @@ -315,7 +316,7 @@ async def _read_paper(paper_id: str, section: str = None) -> tuple[str, bool]: for i, s in enumerate(sections): indent = " " if s.get("level", 2) > 2 else "" toc.append(f"{indent}{i}. {s['title']}") - toc.append(f"\nUse read_paper with section= to read a section.") + toc.append("\nUse read_paper with section= to read a section.") return "\n".join(toc), True target = _find_section(sections, section) @@ -487,7 +488,7 @@ def _to_openalex_id(paper_id: str) -> str: return paper_id -def _extract_arxiv_id(text: str) -> Optional[str]: +def _extract_arxiv_id(text: str) -> str | None: match = re.search(r'(\d{4}\.\d{4,5}(?:v\d+)?)', text) if match: return match.group(1) @@ -497,16 +498,16 @@ def _extract_arxiv_id(text: str) -> Optional[str]: return None -def _extract_arxiv_from_ids(ids: dict) -> Optional[str]: +def _extract_arxiv_from_ids(ids: dict) -> str | None: """Extract arxiv ID from OpenAlex ids dict.""" - openalex_id = ids.get("openalex", "") + ids.get("openalex", "") doi = ids.get("doi", "") if "arXiv" in doi: return _extract_arxiv_id(doi) return None -def _reconstruct_abstract(inverted_index: dict) -> Optional[str]: +def _reconstruct_abstract(inverted_index: dict) -> str | None: """Reconstruct abstract from OpenAlex's inverted index format.""" if not inverted_index: return None @@ -549,7 +550,7 @@ def _parse_sections(soup) -> list[dict]: return sections -def _find_section(sections: list[dict], query: str) -> Optional[dict]: +def _find_section(sections: list[dict], query: str) -> dict | None: try: idx = int(query) if 0 <= idx < len(sections): diff --git a/backend/openmlr/tools/plan.py b/backend/openmlr/tools/plan.py index 4323a5b..609bc4e 100644 --- a/backend/openmlr/tools/plan.py +++ b/backend/openmlr/tools/plan.py @@ -4,8 +4,9 @@ """ import logging -from datetime import datetime, timezone -from ..agent.types import ToolSpec, AgentEvent +from datetime import UTC, datetime + +from ..agent.types import AgentEvent, ToolSpec from ..db import operations as ops logger = logging.getLogger("openmlr.tools.plan") @@ -98,33 +99,33 @@ async def _handle_plan( if operation == "create": if not tasks: return "Provide 'tasks' array.", False - + task_list = [{"title": t.get("title", ""), "status": t.get("status", "pending")} for t in tasks] await ops.upsert_conversation_tasks(db, conv_id, task_list) await _emit_plan(session, conv_id, db) - + # Auto-save plan as PLAN.md resource (pinned) plan_md = _generate_plan_md(task_list) await ops.upsert_plan_resource(db, conv_id, plan_md) await _emit_resources(session, conv_id, db) - + return await _format_plan(db, conv_id), True elif operation == "add": if not title: return "Provide 'title'.", False - + # Get existing tasks and append existing = await ops.get_conversation_tasks(db, conv_id) task_list = [{"title": t.title, "status": t.status, "priority": t.priority} for t in existing] task_list.append({"title": title, "status": "pending"}) await ops.upsert_conversation_tasks(db, conv_id, task_list) await _emit_plan(session, conv_id, db) - + # Update PLAN.md await ops.upsert_plan_resource(db, conv_id, _generate_plan_md(task_list)) await _emit_resources(session, conv_id, db) - + return await _format_plan(db, conv_id), True elif operation == "update": @@ -138,7 +139,7 @@ async def _handle_plan( task = existing[task_index] old_status = task.status - + # ENFORCEMENT: When starting a new task (in_progress), check if previous in_progress task has a report if status == "in_progress" and old_status != "in_progress": in_progress_tasks = [i for i, t in enumerate(existing) if t.status == "in_progress"] @@ -160,7 +161,7 @@ async def _handle_plan( f"2. Cancel task {prev_idx} if it's no longer needed\n\n" f"This ensures a completion report is generated before moving on." ), False - + # Update status task_list = [{"title": t.title, "status": t.status, "priority": t.priority} for t in existing] task_list[task_index]["status"] = status @@ -178,10 +179,10 @@ async def _handle_plan( f"summary='Found 5 relevant papers on X technique...', " f"next_hints='Review paper Y for implementation details')" ), False - + report = _generate_completion_report(task.title, summary, next_hints) report_id = f"report-{task_index}-{len(existing)}" - + await ops.add_conversation_resource( db, conv_id, title=f"Report: {task.title}", @@ -216,14 +217,14 @@ async def _handle_plan( elif operation == "add_resource": if not title: return "Provide 'title'.", False - + resource_id = None resource_content = None if resource_type == "report" and content: import uuid resource_id = f"report-manual-{str(uuid.uuid4())[:8]}" resource_content = content - + await ops.add_conversation_resource( db, conv_id, title=title, @@ -248,7 +249,7 @@ async def get_report_content(report_id: str) -> str | None: def _generate_plan_md(tasks: list[dict]) -> str: """Generate a PLAN.md markdown document from the task list.""" - now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") icons = {"pending": "- [ ]", "in_progress": "- [~]", "completed": "- [x]", "cancelled": "- [-]"} lines = [ "# Plan", @@ -266,23 +267,23 @@ def _generate_plan_md(tasks: list[dict]) -> str: def _generate_completion_report(task_title: str, summary: str = None, next_hints: str = None) -> str: """Generate a structured markdown completion report.""" - now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + now = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC") lines = [ f"# Task Completion Report: {task_title}", - f"", + "", f"**Completed**: {now}", - f"", - f"## Summary", - f"", + "", + "## Summary", + "", summary or "No summary provided.", - f"", + "", ] if next_hints: lines.extend([ - f"## Next Steps", - f"", + "## Next Steps", + "", next_hints, - f"", + "", ]) return "\n".join(lines) diff --git a/backend/openmlr/tools/registry.py b/backend/openmlr/tools/registry.py index 81a90ce..2099d5e 100644 --- a/backend/openmlr/tools/registry.py +++ b/backend/openmlr/tools/registry.py @@ -1,9 +1,8 @@ """ToolRouter — registers, dispatches, and manages all agent tools.""" import inspect -from typing import Optional -from ..agent.types import ToolSpec, ToolCall +from ..agent.types import ToolSpec # Define which tools are allowed in each mode # Tools not listed are allowed in all modes @@ -64,15 +63,15 @@ def get_mode(self) -> str: def is_tool_allowed(self, name: str) -> tuple[bool, str]: """Check if a tool is allowed in the current mode. - + Returns (allowed, error_message). Supports both 'allowed' (whitelist) and 'blocked' (blacklist) sets. """ if self._current_mode not in MODE_TOOL_RESTRICTIONS: return True, "" - + restrictions = MODE_TOOL_RESTRICTIONS[self._current_mode] - + # Blacklist mode: specific tools are blocked blocked_tools = restrictions.get("blocked", set()) if blocked_tools: @@ -80,22 +79,22 @@ def is_tool_allowed(self, name: str) -> tuple[bool, str]: error_msg = restrictions.get("blocked_message", "Tool '{tool}' not allowed in this mode.") return False, error_msg.format(tool=name, mode=self._current_mode) return True, "" - + # Whitelist mode: only specific tools allowed allowed_tools = restrictions.get("allowed", set()) if name in allowed_tools: return True, "" - + error_msg = restrictions.get("blocked_message", "Tool '{tool}' not allowed in this mode.") return False, error_msg.format(tool=name, mode=self._current_mode) - def get_tool(self, name: str) -> Optional[ToolSpec]: + def get_tool(self, name: str) -> ToolSpec | None: """Look up a tool by name.""" return self.tools.get(name) def get_tool_specs_for_llm(self, filter_by_mode: bool = True) -> list[dict]: """Convert registered tools to OpenAI function-calling format. - + If filter_by_mode is True, only returns tools allowed in the current mode. """ specs = [] @@ -105,7 +104,7 @@ def get_tool_specs_for_llm(self, filter_by_mode: bool = True) -> list[dict]: allowed, _ = self.is_tool_allowed(tool.name) if not allowed: continue - + specs.append({ "type": "function", "function": { @@ -128,7 +127,7 @@ async def call_tool( enforce_mode: bool = True, ) -> tuple[str, bool]: """Execute a tool call, dispatching to handler or MCP. - + If enforce_mode is True, checks if the tool is allowed in the current mode. """ # Check mode restrictions first @@ -141,7 +140,7 @@ async def call_tool( f"To use this tool, ask the user to switch modes using ask_user with suggest_mode parameter." ) return warning, False - + tool = self.tools.get(name) if not tool: return f"Unknown tool: {name}", False @@ -218,14 +217,14 @@ def create_tool_router(sandbox_manager=None) -> ToolRouter: router = ToolRouter() # Import and register all built-in tools - from .local import create_local_tools + from .ask_user import create_ask_user_tool from .github import create_github_tools - from .search import create_search_tools - from .research import create_research_tool - from .plan import create_plan_tool + from .local import create_local_tools from .papers import create_papers_tool + from .plan import create_plan_tool + from .research import create_research_tool + from .search import create_search_tools from .writing import create_writing_tool - from .ask_user import create_ask_user_tool router.register_many(create_local_tools()) router.register_many(create_github_tools()) diff --git a/backend/openmlr/tools/research.py b/backend/openmlr/tools/research.py index 4c75b62..3d6d7b9 100644 --- a/backend/openmlr/tools/research.py +++ b/backend/openmlr/tools/research.py @@ -1,14 +1,13 @@ """Research sub-agent — spawns independent context for deep research.""" -import asyncio import json import time -from ..agent.types import ToolSpec, Message, AgentEvent, ToolCall -from ..agent.llm import LLMProvider + from ..agent.doom_loop import detect_doom_loop +from ..agent.llm import LLMProvider +from ..agent.types import AgentEvent, Message, ToolCall, ToolSpec from ..config import AgentConfig - MAX_RESEARCH_ITERATIONS = 60 TOKEN_WARN_THRESHOLD = 170000 TOKEN_FORCE_STOP = 190000 @@ -136,7 +135,7 @@ async def _handle_research(query: str, focus: str = "general", session=None, **k # Execute tools and emit granular events for tc in result.tool_calls: tool_count += 1 - + # Emit sub-agent tool call if session: await session.emit(AgentEvent( @@ -207,9 +206,9 @@ async def _handle_research(query: str, focus: str = "general", session=None, **k def _get_research_tool_specs() -> list[dict]: """Get the read-only tool subset for research.""" - from .search import create_search_tools - from .papers import create_papers_tool from .github import create_github_tools + from .papers import create_papers_tool + from .search import create_search_tools tools = [] for spec in create_search_tools(): @@ -248,9 +247,9 @@ def _get_research_tool_specs() -> list[dict]: async def _execute_research_tool(tc: ToolCall) -> tuple[str, bool]: """Execute a tool call for the research sub-agent.""" - from .search import _handle_web_search + from .github import _handle_find_examples, _handle_read_file from .papers import _handle_papers - from .github import _handle_read_file, _handle_find_examples + from .search import _handle_web_search handlers = { "web_search": _handle_web_search, diff --git a/backend/openmlr/tools/sandbox_tools.py b/backend/openmlr/tools/sandbox_tools.py index a017b8b..c73322a 100644 --- a/backend/openmlr/tools/sandbox_tools.py +++ b/backend/openmlr/tools/sandbox_tools.py @@ -150,7 +150,7 @@ async def _local_probe() -> str: async def _handle_create(sandbox_manager, provider: str, config: dict = None, session=None, **kwargs) -> tuple[str, bool]: try: - sandbox = await sandbox_manager.create(provider, config or {}) + await sandbox_manager.create(provider, config or {}) return f"Sandbox created: {provider} ({sandbox_manager.active_type})", True except Exception as e: return f"Failed to create sandbox: {str(e)}", False diff --git a/backend/openmlr/tools/search.py b/backend/openmlr/tools/search.py index 0281b7e..6defd9c 100644 --- a/backend/openmlr/tools/search.py +++ b/backend/openmlr/tools/search.py @@ -1,7 +1,9 @@ """Web search tool — Brave Search API.""" import os + import httpx + from ..agent.types import ToolSpec diff --git a/backend/openmlr/tools/writing.py b/backend/openmlr/tools/writing.py index 78f6d72..d31adad 100644 --- a/backend/openmlr/tools/writing.py +++ b/backend/openmlr/tools/writing.py @@ -6,8 +6,9 @@ import json import logging -from datetime import datetime, timezone -from ..agent.types import ToolSpec, AgentEvent +from datetime import UTC, datetime + +from ..agent.types import AgentEvent, ToolSpec from ..db import operations as ops logger = logging.getLogger("openmlr.tools.writing") @@ -31,7 +32,7 @@ async def _load_project(conv_id: int) -> dict | None: """Load project from DB if not already cached.""" if conv_id in _projects: return _projects[conv_id] - + session_factory = _get_session_factory() async with session_factory() as db: resource = await ops.get_resource_by_id(db, f"paper-{conv_id}") @@ -51,7 +52,7 @@ async def _load_project(conv_id: int) -> dict | None: async def _save_project(conv_id: int, proj: dict) -> None: """Save project metadata and draft to DB.""" _projects[conv_id] = proj - + session_factory = _get_session_factory() async with session_factory() as db: # Save project metadata (structure, bibliography, etc.) @@ -225,7 +226,7 @@ def _create_project(conv_id: int, title: str) -> tuple[str, bool]: "outline": [], "sections": {}, "bibliography": [], - "created_at": datetime.now(timezone.utc).isoformat(), + "created_at": datetime.now(UTC).isoformat(), } if conv_id: _projects[conv_id] = proj diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7b7824f..f9a1a48 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -66,8 +66,46 @@ dev-dependencies = [ "pytest-cov>=6.0.0", "aiosqlite>=0.20.0", "httpx>=0.28.0", + "ruff>=0.8.0", ] +[tool.ruff] +target-version = "py312" +line-length = 100 +exclude = [ + ".git", + ".venv", + "__pycache__", + "**/migrations/**", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) + "E402", # module level import not at top (needed for tests with pytestmark) + "E712", # comparison to True/False (sometimes clearer in SQLAlchemy) + "B008", # do not perform function calls in argument defaults + "B904", # raise from err (too noisy for now) + "B007", # loop control variable not used (intentional in some cases) + "B017", # assert blind exception (ok in tests) + "UP042", # StrEnum (requires Python 3.11+ consideration) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["F841"] # unused variables ok in tests + +[tool.ruff.lint.isort] +known-first-party = ["openmlr"] + [tool.coverage.run] source = ["openmlr"] omit = ["openmlr/db/migrations/*"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0731f80..191665f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -11,23 +11,23 @@ pytestmark = pytest.mark.asyncio import asyncio -from typing import AsyncGenerator +from collections.abc import AsyncGenerator import httpx import pytest_asyncio + +# --------------------------------------------------------------------------- +# SQLite compatibility: replace PostgreSQL-only ARRAY column with JSON +# --------------------------------------------------------------------------- +from sqlalchemy import JSON as _JSON from sqlalchemy.ext.asyncio import ( AsyncSession, async_sessionmaker, create_async_engine, ) -from openmlr.db.models import Base, User, ResearchCorpus -from openmlr.auth.security import hash_password, create_access_token - -# --------------------------------------------------------------------------- -# SQLite compatibility: replace PostgreSQL-only ARRAY column with JSON -# --------------------------------------------------------------------------- -from sqlalchemy import JSON as _JSON +from openmlr.auth.security import create_access_token, hash_password +from openmlr.db.models import Base, ResearchCorpus, User ResearchCorpus.__table__.c.tags.type = _JSON() @@ -103,9 +103,9 @@ async def client() -> AsyncGenerator[httpx.AsyncClient, None]: # Import lazily so module-level side-effects (engine creation, dotenv) # don't interfere before we apply the override. from openmlr.app import app + from openmlr.config import AgentConfig from openmlr.db.engine import get_db as engine_get_db from openmlr.dependencies import get_db as dep_get_db - from openmlr.config import AgentConfig # Override both the canonical get_db *and* the re-export in dependencies app.dependency_overrides[engine_get_db] = _override_get_db diff --git a/backend/tests/test_agent_loop.py b/backend/tests/test_agent_loop.py index 77a6e77..dab8179 100644 --- a/backend/tests/test_agent_loop.py +++ b/backend/tests/test_agent_loop.py @@ -1,15 +1,29 @@ """Tests for agent loop — tool execution, approval, undo, compact, submissions.""" -import pytest from unittest.mock import AsyncMock, MagicMock, patch -from openmlr.agent.types import AgentEvent, Message, ToolCall, ToolSpec, Submission, OpType, LLMResult -from openmlr.agent.session import Session +import pytest + from openmlr.agent.context import ContextManager from openmlr.agent.loop import ( - _execute_tool, _handle_approval, _undo, _compact, - _run_agent, run_agent_turn, submission_loop, - _stream_llm_call, _non_stream_llm_call, _compact_llm_call, + _compact, + _compact_llm_call, + _execute_tool, + _handle_approval, + _non_stream_llm_call, + _run_agent, + _stream_llm_call, + _undo, + run_agent_turn, + submission_loop, +) +from openmlr.agent.session import Session +from openmlr.agent.types import ( + AgentEvent, + LLMResult, + OpType, + Submission, + ToolCall, ) from openmlr.config import AgentConfig from openmlr.tools.registry import ToolRouter diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index 222eeb2..0137534 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -1,6 +1,7 @@ """Tests for app entrypoint and main module.""" import pytest + from openmlr.app import app pytestmark = pytest.mark.asyncio @@ -26,7 +27,6 @@ async def test_cors_middleware_configured(self): assert CORSMiddleware in middlewares async def test_global_exception_handler_configured(self): - from starlette.responses import JSONResponse handlers = app.exception_handlers assert Exception in handlers @@ -37,7 +37,8 @@ async def test_main_is_callable(self): assert callable(main) async def test_main_contains_uvicorn_import(self): - from openmlr.main import main import inspect + + from openmlr.main import main source = inspect.getsource(main) assert "uvicorn" in source diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index a4cad55..2903844 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,17 +1,17 @@ """Tests for openmlr.auth.security — password hashing and JWT tokens.""" -import time +from datetime import UTC + import pytest -from unittest.mock import patch from jose import jwt from openmlr.auth.security import ( - hash_password, - verify_password, + ALGORITHM, + SECRET_KEY, create_access_token, decode_access_token, - SECRET_KEY, - ALGORITHM, + hash_password, + verify_password, ) @@ -97,12 +97,12 @@ def test_returns_none_for_wrong_secret(self): assert decode_access_token(bad_token) is None def test_returns_none_for_expired_token(self): - from datetime import datetime, timezone, timedelta + from datetime import datetime, timedelta expired_payload = { "sub": "1", "username": "frank", - "exp": datetime.now(timezone.utc) - timedelta(hours=1), + "exp": datetime.now(UTC) - timedelta(hours=1), } expired_token = jwt.encode(expired_payload, SECRET_KEY, algorithm=ALGORITHM) assert decode_access_token(expired_token) is None diff --git a/backend/tests/test_celery_app.py b/backend/tests/test_celery_app.py index 21c2cda..837b692 100644 --- a/backend/tests/test_celery_app.py +++ b/backend/tests/test_celery_app.py @@ -1,6 +1,5 @@ """Tests for Celery app configuration.""" -import pytest from celery import Celery @@ -38,5 +37,5 @@ def test_task_routing_configured(self): assert routes is not None def test_get_celery_app(self): - from openmlr.celery_app import get_celery_app, celery_app + from openmlr.celery_app import celery_app, get_celery_app assert get_celery_app() is celery_app diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index dfdbef5..ed4699f 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -2,9 +2,8 @@ import pytest -from openmlr.config import AgentConfig, get_model_max_tokens, MODEL_MAX_TOKENS from openmlr.agent.context import estimate_tokens - +from openmlr.config import AgentConfig, get_model_max_tokens # --------------------------------------------------------------------------- # AgentConfig defaults diff --git a/backend/tests/test_context.py b/backend/tests/test_context.py index 8c73787..3f51c6a 100644 --- a/backend/tests/test_context.py +++ b/backend/tests/test_context.py @@ -1,6 +1,7 @@ """Tests for openmlr.agent.context — ContextManager and helpers.""" import pytest + from openmlr.agent.context import ContextManager, estimate_tokens from openmlr.agent.types import Message, ToolCall from openmlr.config import AgentConfig diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py index 52f5aaf..744fb83 100644 --- a/backend/tests/test_conversations.py +++ b/backend/tests/test_conversations.py @@ -10,7 +10,6 @@ from __future__ import annotations -import asyncio from unittest.mock import AsyncMock, MagicMock import pytest diff --git a/backend/tests/test_db_engine.py b/backend/tests/test_db_engine.py index aa47ab3..95b71a9 100644 --- a/backend/tests/test_db_engine.py +++ b/backend/tests/test_db_engine.py @@ -18,8 +18,9 @@ def test_async_session_created(self): assert async_session is not None def test_worker_engine_context_var(self): - from openmlr.db.engine import _worker_engine from contextvars import ContextVar + + from openmlr.db.engine import _worker_engine assert isinstance(_worker_engine, ContextVar) diff --git a/backend/tests/test_db_operations.py b/backend/tests/test_db_operations.py index ba7263d..c53050a 100644 --- a/backend/tests/test_db_operations.py +++ b/backend/tests/test_db_operations.py @@ -7,7 +7,6 @@ pytestmark = pytest.mark.asyncio from openmlr.db import operations as ops -from openmlr.db.models import UserSetting class TestConversationOperations: @@ -86,8 +85,8 @@ async def test_increment_user_message_count(self, db_session: AsyncSession, test async def test_conversations_isolated_by_user(self, db_session: AsyncSession, test_user): # Create another user - from openmlr.db.models import User from openmlr.auth.security import hash_password + from openmlr.db.models import User user2 = User(username="user2", password_hash=hash_password("pwd"), is_active=True) db_session.add(user2) await db_session.flush() diff --git a/backend/tests/test_doom_loop.py b/backend/tests/test_doom_loop.py index 5b60a7e..e9a4f66 100644 --- a/backend/tests/test_doom_loop.py +++ b/backend/tests/test_doom_loop.py @@ -1,8 +1,9 @@ """Tests for openmlr.agent.doom_loop — repetitive tool-call detection.""" import pytest -from openmlr.agent.types import Message, ToolCall + from openmlr.agent.doom_loop import detect_doom_loop +from openmlr.agent.types import Message, ToolCall # Override the autouse DB fixture from conftest — these tests are pure unit tests. diff --git a/backend/tests/test_event_bus.py b/backend/tests/test_event_bus.py index 6f33a3b..628ae22 100644 --- a/backend/tests/test_event_bus.py +++ b/backend/tests/test_event_bus.py @@ -3,11 +3,9 @@ import asyncio import pytest -import pytest_asyncio -from openmlr.services.event_bus import EventBus from openmlr.agent.types import AgentEvent - +from openmlr.services.event_bus import EventBus # --------------------------------------------------------------------------- # Fixtures diff --git a/backend/tests/test_job_manager.py b/backend/tests/test_job_manager.py index 8305cb8..401207e 100644 --- a/backend/tests/test_job_manager.py +++ b/backend/tests/test_job_manager.py @@ -6,8 +6,8 @@ pytestmark = pytest.mark.asyncio -from openmlr.services.job_manager import JobManager, get_job_manager from openmlr.db import operations as ops +from openmlr.services.job_manager import JobManager, get_job_manager @pytest_asyncio.fixture diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index c1031ee..db1f9da 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -1,13 +1,24 @@ """Tests for Pydantic API models — validation, defaults, serialization.""" +from datetime import UTC + import pytest from pydantic import ValidationError from openmlr.models import ( - UserRegister, UserLogin, TokenResponse, UserInfo, - ConversationCreate, ConversationResponse, MessageResponse, ConversationDetail, - MessageSend, ApprovalRequest, - SettingUpdate, ProviderConfig, ModelSwitch, AgentEvent, + AgentEvent, + ApprovalRequest, + ConversationCreate, + ConversationDetail, + ConversationResponse, + MessageResponse, + MessageSend, + ModelSwitch, + ProviderConfig, + SettingUpdate, + TokenResponse, + UserLogin, + UserRegister, ) @@ -70,8 +81,8 @@ def test_custom(self): class TestConversationResponse: def test_creation(self): - from datetime import datetime, timezone - now = datetime.now(timezone.utc) + from datetime import datetime + now = datetime.now(UTC) c = ConversationResponse( id=1, uuid="abc-def", title="Test Conv", model="gpt-4o", mode="general", user_message_count=5, @@ -84,24 +95,24 @@ def test_creation(self): class TestMessageResponse: def test_creation(self): - from datetime import datetime, timezone - now = datetime.now(timezone.utc) + from datetime import datetime + now = datetime.now(UTC) m = MessageResponse(id=1, role="user", content="Hello", metadata=None, created_at=now) assert m.id == 1 assert m.role == "user" assert m.content == "Hello" def test_with_metadata(self): - from datetime import datetime, timezone - now = datetime.now(timezone.utc) + from datetime import datetime + now = datetime.now(UTC) m = MessageResponse(id=2, role="assistant", content="Hi", metadata={"tool": "search"}, created_at=now) assert m.metadata == {"tool": "search"} class TestConversationDetail: def test_creation(self): - from datetime import datetime, timezone - now = datetime.now(timezone.utc) + from datetime import datetime + now = datetime.now(UTC) conv = ConversationResponse( id=1, uuid="x", title="C", model=None, mode="general", user_message_count=0, created_at=now, updated_at=now, diff --git a/backend/tests/test_models_orm.py b/backend/tests/test_models_orm.py index bd2e5ce..140cf7f 100644 --- a/backend/tests/test_models_orm.py +++ b/backend/tests/test_models_orm.py @@ -7,12 +7,19 @@ pytestmark = pytest.mark.asyncio from sqlalchemy import select +from openmlr.auth.security import hash_password from openmlr.db.models import ( - User, Conversation, Message, ResearchCorpus, - WritingProject, SandboxConfig, ConversationTask, - ConversationResource, AgentJob, UserSetting, + AgentJob, + Conversation, + ConversationResource, + ConversationTask, + Message, + ResearchCorpus, + SandboxConfig, + User, + UserSetting, + WritingProject, ) -from openmlr.auth.security import hash_password class TestUserModel: @@ -237,7 +244,6 @@ async def test_setting_uniqueness(self, db_session: AsyncSession, test_user): await db_session.commit() # Verify we can query the setting back - from sqlalchemy import select result = await db_session.execute( select(UserSetting).where( UserSetting.user_id == test_user.id, diff --git a/backend/tests/test_prompts.py b/backend/tests/test_prompts.py index 6ccd6f1..31483b4 100644 --- a/backend/tests/test_prompts.py +++ b/backend/tests/test_prompts.py @@ -1,8 +1,7 @@ """Tests for system prompt builder.""" -import pytest -from openmlr.agent.prompts import build_system_prompt, COMPACT_PROMPT +from openmlr.agent.prompts import COMPACT_PROMPT, build_system_prompt from openmlr.agent.types import ToolSpec diff --git a/backend/tests/test_redis_pubsub.py b/backend/tests/test_redis_pubsub.py index 80fd6d9..cd0c3bd 100644 --- a/backend/tests/test_redis_pubsub.py +++ b/backend/tests/test_redis_pubsub.py @@ -1,7 +1,8 @@ """Tests for Redis pub/sub — event publishing, answer relay, interrupt signaling.""" +from unittest.mock import AsyncMock, patch + import pytest -from unittest.mock import AsyncMock, MagicMock, patch @pytest.mark.asyncio @@ -138,7 +139,6 @@ async def test_returns_answers_when_set(self): mock_redis.delete.assert_called_once() async def test_returns_none_on_timeout(self): - import time from openmlr.services.redis_pubsub import wait_for_answers mock_redis = AsyncMock() @@ -176,6 +176,7 @@ def test_interrupt_key_prefix(self): def test_redis_url_from_env(self, monkeypatch): monkeypatch.setenv("REDIS_URL", "redis://custom:6379/1") from importlib import reload + import openmlr.services.redis_pubsub reload(openmlr.services.redis_pubsub) assert openmlr.services.redis_pubsub.REDIS_URL == "redis://custom:6379/1" diff --git a/backend/tests/test_routes_settings.py b/backend/tests/test_routes_settings.py index 91db400..fc4af07 100644 --- a/backend/tests/test_routes_settings.py +++ b/backend/tests/test_routes_settings.py @@ -1,6 +1,7 @@ """Tests for settings API routes.""" import os + import pytest from httpx import AsyncClient diff --git a/backend/tests/test_sandbox_manager.py b/backend/tests/test_sandbox_manager.py index ca0a751..8c50a95 100644 --- a/backend/tests/test_sandbox_manager.py +++ b/backend/tests/test_sandbox_manager.py @@ -1,6 +1,7 @@ """Tests for SandboxManager — lifecycle management and provider selection.""" import pytest + from openmlr.sandbox.manager import SandboxManager pytestmark = pytest.mark.asyncio diff --git a/backend/tests/test_sandbox_types.py b/backend/tests/test_sandbox_types.py index d4cbd5f..fc7553f 100644 --- a/backend/tests/test_sandbox_types.py +++ b/backend/tests/test_sandbox_types.py @@ -1,12 +1,9 @@ """Tests for sandbox interface types and LocalSandbox.""" -import tempfile -from pathlib import Path import pytest from openmlr.sandbox.interface import EnvironmentInfo, ExecutionResult, SandboxInterface from openmlr.sandbox.local import LocalSandbox -from openmlr.sandbox.manager import SandboxManager class TestEnvironmentInfo: diff --git a/backend/tests/test_session.py b/backend/tests/test_session.py index 8f61d21..1e52399 100644 --- a/backend/tests/test_session.py +++ b/backend/tests/test_session.py @@ -3,14 +3,12 @@ import asyncio import pytest -import pytest_asyncio -from openmlr.agent.session import Session from openmlr.agent.context import ContextManager +from openmlr.agent.session import Session from openmlr.agent.types import AgentEvent from openmlr.config import AgentConfig - # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- diff --git a/backend/tests/test_session_manager.py b/backend/tests/test_session_manager.py index 6990d70..5e56118 100644 --- a/backend/tests/test_session_manager.py +++ b/backend/tests/test_session_manager.py @@ -1,11 +1,12 @@ """Tests for SessionManager — multi-session lifecycle and message queuing.""" import pytest -from openmlr.services.session_manager import SessionManager, ActiveSession + +from openmlr.services.session_manager import SessionManager pytestmark = pytest.mark.asyncio -from openmlr.services.event_bus import EventBus from openmlr.config import AgentConfig +from openmlr.services.event_bus import EventBus @pytest.fixture diff --git a/backend/tests/test_tool_registry.py b/backend/tests/test_tool_registry.py index 2f835ef..5811350 100644 --- a/backend/tests/test_tool_registry.py +++ b/backend/tests/test_tool_registry.py @@ -1,10 +1,11 @@ """Tests for ToolRouter — registration, dispatch, mode filtering.""" import pytest -from openmlr.tools.registry import ToolRouter, MODE_TOOL_RESTRICTIONS + +from openmlr.tools.registry import MODE_TOOL_RESTRICTIONS, ToolRouter pytestmark = pytest.mark.asyncio -from openmlr.agent.types import ToolSpec, ToolCall +from openmlr.agent.types import ToolSpec @pytest.fixture diff --git a/backend/tests/test_tools_github.py b/backend/tests/test_tools_github.py index 3b6a5e7..75f59ac 100644 --- a/backend/tests/test_tools_github.py +++ b/backend/tests/test_tools_github.py @@ -1,7 +1,8 @@ """Tests for GitHub tools — tool specs and helper functions.""" import pytest -from openmlr.tools.github import create_github_tools, _headers + +from openmlr.tools.github import _headers, create_github_tools pytestmark = pytest.mark.asyncio diff --git a/backend/tests/test_tools_local.py b/backend/tests/test_tools_local.py index 575b7b6..e2608d0 100644 --- a/backend/tests/test_tools_local.py +++ b/backend/tests/test_tools_local.py @@ -1,13 +1,18 @@ """Tests for local tools — bash, read, write, edit, and path validation.""" import os -import tempfile from pathlib import Path + import pytest from openmlr.tools.local import ( - create_local_tools, _validate_path, _handle_read, _handle_write, _handle_edit, - DOCKER_IMAGE, CONTAINER_PREFIX, ALLOW_DIRECT_EXEC, WORKSPACE_ROOT, + CONTAINER_PREFIX, + DOCKER_IMAGE, + _handle_edit, + _handle_read, + _handle_write, + _validate_path, + create_local_tools, ) diff --git a/backend/tests/test_tools_mcp.py b/backend/tests/test_tools_mcp.py index 0cddbee..9b10c3d 100644 --- a/backend/tests/test_tools_mcp.py +++ b/backend/tests/test_tools_mcp.py @@ -1,7 +1,8 @@ """Tests for MCP tools — env var substitution and config processing.""" import pytest -from openmlr.tools.mcp import substitute_env_vars, process_mcp_config + +from openmlr.tools.mcp import process_mcp_config, substitute_env_vars pytestmark = pytest.mark.asyncio diff --git a/backend/tests/test_tools_papers.py b/backend/tests/test_tools_papers.py index 37b8803..adb922a 100644 --- a/backend/tests/test_tools_papers.py +++ b/backend/tests/test_tools_papers.py @@ -1,10 +1,15 @@ """Tests for papers tool — helper functions and tool spec.""" import pytest + from openmlr.tools.papers import ( - create_papers_tool, _to_openalex_id, _extract_arxiv_id, - _extract_arxiv_from_ids, _reconstruct_abstract, _check_budget, - _increment_budget, _get_budget_info, + _check_budget, + _extract_arxiv_id, + _get_budget_info, + _increment_budget, + _reconstruct_abstract, + _to_openalex_id, + create_papers_tool, ) pytestmark = pytest.mark.asyncio diff --git a/backend/tests/test_tools_research.py b/backend/tests/test_tools_research.py index 3577f82..35eddd4 100644 --- a/backend/tests/test_tools_research.py +++ b/backend/tests/test_tools_research.py @@ -1,11 +1,15 @@ """Tests for research tool — tool specs and the research sub-agent dispatching.""" import pytest + +from openmlr.agent.types import ToolCall from openmlr.tools.research import ( - create_research_tool, _get_research_tool_specs, _execute_research_tool, - RESEARCH_SYSTEM_PROMPT, MAX_RESEARCH_ITERATIONS, + MAX_RESEARCH_ITERATIONS, + RESEARCH_SYSTEM_PROMPT, + _execute_research_tool, + _get_research_tool_specs, + create_research_tool, ) -from openmlr.agent.types import ToolCall class TestCreateResearchTool: diff --git a/backend/tests/test_tools_sandbox.py b/backend/tests/test_tools_sandbox.py index e05a486..4ea5e95 100644 --- a/backend/tests/test_tools_sandbox.py +++ b/backend/tests/test_tools_sandbox.py @@ -1,7 +1,8 @@ """Tests for sandbox tools — probe, create, exec, read, write.""" import pytest -from openmlr.tools.sandbox_tools import create_sandbox_tools, _handle_probe + +from openmlr.tools.sandbox_tools import _handle_probe, create_sandbox_tools pytestmark = pytest.mark.asyncio from openmlr.sandbox.manager import SandboxManager diff --git a/backend/tests/test_tools_search.py b/backend/tests/test_tools_search.py index 36c9f11..c30b1d5 100644 --- a/backend/tests/test_tools_search.py +++ b/backend/tests/test_tools_search.py @@ -1,6 +1,7 @@ """Tests for web search tool — tool spec validation.""" import pytest + from openmlr.tools.search import create_search_tools pytestmark = pytest.mark.asyncio diff --git a/backend/tests/test_tools_writing.py b/backend/tests/test_tools_writing.py index bb72e20..30b18bf 100644 --- a/backend/tests/test_tools_writing.py +++ b/backend/tests/test_tools_writing.py @@ -1,11 +1,18 @@ """Tests for writing tool — project management and paper operations.""" import pytest + from openmlr.tools.writing import ( - create_writing_tool, _create_project, _set_outline, - _write_section, _get_draft, _get_draft_from_proj, - _list_sections, _add_citation, _refine_section, + _add_citation, _count_sections, + _create_project, + _get_draft, + _get_draft_from_proj, + _list_sections, + _refine_section, + _set_outline, + _write_section, + create_writing_tool, ) pytestmark = pytest.mark.asyncio diff --git a/backend/tests/test_types.py b/backend/tests/test_types.py index 4271696..7ab61d3 100644 --- a/backend/tests/test_types.py +++ b/backend/tests/test_types.py @@ -1,10 +1,15 @@ """Tests for agent core types — AgentEvent, OpType, Message, ToolCall, ToolSpec, LLMResult, Submission.""" import json -import pytest from openmlr.agent.types import ( - AgentEvent, OpType, Message, ToolCall, ToolSpec, LLMResult, Submission, + AgentEvent, + LLMResult, + Message, + OpType, + Submission, + ToolCall, + ToolSpec, ) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ab01268 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,47 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'node_modules', 'coverage'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + // React hooks rules (more lenient) + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + // TypeScript rules + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + // General rules + 'no-console': ['warn', { allow: ['warn', 'error', 'debug'] }], + 'prefer-const': 'error', + }, + }, + // Test files - more lenient rules + { + files: ['**/__tests__/**/*.{ts,tsx}', '**/*.test.{ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'no-console': 'off', + }, + } +); diff --git a/frontend/package.json b/frontend/package.json index 9e0ec63..d3f79d4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,9 @@ "build": "tsc && vite build", "preview": "vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix" }, "dependencies": { "react": "^19.0.0", @@ -18,14 +20,20 @@ "remark-gfm": "^4.0.0" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "eslint": "^10.2.1", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.5.0", "jsdom": "^29.0.2", "typescript": "^5.6.0", + "typescript-eslint": "^8.59.0", "vite": "^6.0.0", "vitest": "^4.1.5" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f09599c..edf183a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -269,7 +269,7 @@ function ChatUI({ case 'assistant_message': if (data?.content) { setMessages((prev) => { - let msgs = prev.filter((m) => !(m.role === 'system' && m.content === '::thinking::')); + const msgs = prev.filter((m) => !(m.role === 'system' && m.content === '::thinking::')); if (msgs[msgs.length - 1]?.role === 'assistant') return msgs; return [...msgs, { id: nextId(), role: 'assistant', content: data.content }]; }); @@ -277,7 +277,7 @@ function ChatUI({ break; case 'tool_call': setMessages((prev) => { - let msgs = prev.filter((m) => !(m.role === 'system' && m.content === '::thinking::')); + const msgs = prev.filter((m) => !(m.role === 'system' && m.content === '::thinking::')); return [...msgs, { id: nextId(), role: 'tool', content: '', metadata: { tool: data?.tool ?? '', tool_call_id: data?.id, args: typeof data?.arguments === 'string' ? data.arguments.slice(0, 120) : JSON.stringify(data?.arguments ?? {}).slice(0, 120) } }]; }); break; @@ -295,7 +295,7 @@ function ChatUI({ // Sub-agent events case 'sub_agent_start': setMessages((prev) => { - let msgs = prev.filter((m) => !(m.role === 'system' && m.content === '::thinking::')); + const msgs = prev.filter((m) => !(m.role === 'system' && m.content === '::thinking::')); return [...msgs, { id: nextId(), role: 'tool', content: '', metadata: { tool: `sub_agent:${data?.agent_type || 'task'}`, tool_call_id: data?.parent_tool_call_id, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41a7cfe..b86f670 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: specifier: ^4.0.0 version: 4.0.1 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.2.1) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -44,12 +47,27 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.0 version: 4.7.0(vite@6.4.2) + eslint: + specifier: ^10.2.1 + version: 10.2.1 + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.2.1) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.2.1) + globals: + specifier: ^17.5.0 + version: 17.5.0 jsdom: specifier: ^29.0.2 version: 29.0.2 typescript: specifier: ^5.6.0 version: 5.9.3 + typescript-eslint: + specifier: ^8.59.0 + version: 8.59.0(eslint@10.2.1)(typescript@5.9.3) vite: specifier: ^6.0.0 version: 6.4.2 @@ -360,6 +378,45 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + 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.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.5': + resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.1': + resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -369,6 +426,26 @@ packages: '@noble/hashes': optional: true + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + 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'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -582,6 +659,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -591,6 +671,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -611,6 +694,65 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@8.59.0': + resolution: {integrity: sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.0': + resolution: {integrity: sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.0': + resolution: {integrity: sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.0': + resolution: {integrity: sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.0': + resolution: {integrity: sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.0': + resolution: {integrity: sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.0': + resolution: {integrity: sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.0': + resolution: {integrity: sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.0': + resolution: {integrity: sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.0': + resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -649,6 +791,19 @@ packages: '@vitest/utils@4.1.5': resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -671,6 +826,10 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.21: resolution: {integrity: sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==} engines: {node: '>=6.0.0'} @@ -679,6 +838,10 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -716,6 +879,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -745,6 +912,9 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -777,16 +947,73 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + 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 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + 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@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.2.1: + resolution: {integrity: sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + 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'} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -794,6 +1021,15 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -803,6 +1039,21 @@ packages: picomatch: optional: true + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + 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.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -812,12 +1063,26 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.5.0: + resolution: {integrity: sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==} + engines: {node: '>=18'} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -825,6 +1090,18 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + 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'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -841,6 +1118,14 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -851,6 +1136,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -868,11 +1156,31 @@ packages: 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@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -1029,6 +1337,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1037,18 +1349,41 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-releases@2.0.38: resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + 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'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + 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'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1063,6 +1398,10 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -1149,9 +1488,22 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + 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'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1220,6 +1572,23 @@ packages: trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.59.0: + resolution: {integrity: sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1253,6 +1622,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -1356,11 +1728,20 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -1371,6 +1752,19 @@ packages: 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.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -1618,8 +2012,58 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1)': + dependencies: + eslint: 10.2.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.5': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.2.1)': + optionalDependencies: + eslint: 10.2.1 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.1': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1786,6 +2230,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -1796,6 +2242,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -1814,6 +2262,97 @@ snapshots: '@types/unist@3.0.3': {} + '@typescript-eslint/eslint-plugin@8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1)(typescript@5.9.3))(eslint@10.2.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.0(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/type-utils': 8.59.0(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.0(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.0 + eslint: 10.2.1 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.0(eslint@10.2.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.0 + debug: 4.4.3 + eslint: 10.2.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) + '@typescript-eslint/types': 8.59.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.0': + dependencies: + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 + + '@typescript-eslint/tsconfig-utils@8.59.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.0(eslint@10.2.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.0(eslint@10.2.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.2.1 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.0': {} + + '@typescript-eslint/typescript-estree@8.59.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.0(typescript@5.9.3) + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/visitor-keys': 8.59.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.0(eslint@10.2.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) + '@typescript-eslint/scope-manager': 8.59.0 + '@typescript-eslint/types': 8.59.0 + '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) + eslint: 10.2.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.0': + dependencies: + '@typescript-eslint/types': 8.59.0 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-react@4.7.0(vite@6.4.2)': @@ -1869,6 +2408,19 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + 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-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -1883,12 +2435,18 @@ snapshots: bail@2.0.2: {} + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.21: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.21 @@ -1917,6 +2475,12 @@ snapshots: cookie@1.1.1: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -1943,6 +2507,8 @@ snapshots: dependencies: character-entities: 2.0.2 + deep-is@0.1.4: {} + dequal@2.0.3: {} devlop@1.1.0: @@ -1990,27 +2556,136 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-react-hooks@7.1.1(eslint@10.2.1): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 10.2.1 + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.2(eslint@10.2.1): + dependencies: + eslint: 10.2.1 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.2.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.5.5 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.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 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-util-is-identifier-name@3.0.0: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.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.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + fsevents@2.3.3: optional: true gensync@1.0.0-beta.2: {} + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.5.0: {} + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -2035,6 +2710,12 @@ snapshots: dependencies: '@types/hast': 3.0.4 + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.0 @@ -2043,6 +2724,12 @@ snapshots: html-url-attributes@3.0.1: {} + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + indent-string@4.0.0: {} inline-style-parser@0.2.7: {} @@ -2056,12 +2743,20 @@ snapshots: is-decimal@2.0.1: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} is-plain-obj@4.1.0: {} is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} + js-tokens@4.0.0: {} jsdom@29.0.2: @@ -2092,8 +2787,27 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + longest-streak@3.1.0: {} lru-cache@11.3.5: {} @@ -2458,14 +3172,37 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + ms@2.1.3: {} nanoid@3.3.11: {} + natural-compare@1.4.0: {} + node-releases@2.0.38: {} obug@2.1.1: {} + 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 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -2480,6 +3217,10 @@ snapshots: dependencies: entities: 8.0.0 + path-exists@4.0.0: {} + + path-key@3.1.1: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -2492,6 +3233,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -2625,8 +3368,16 @@ snapshots: semver@6.3.1: {} + semver@7.7.4: {} + set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -2685,6 +3436,25 @@ snapshots: trough@2.2.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.59.0(eslint@10.2.1)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.0(@typescript-eslint/parser@8.59.0(eslint@10.2.1)(typescript@5.9.3))(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.0(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.0(eslint@10.2.1)(typescript@5.9.3) + eslint: 10.2.1 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} undici@7.25.0: {} @@ -2728,6 +3498,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -2792,15 +3566,29 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} yallist@3.1.1: {} + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} + zwitch@2.0.4: {} diff --git a/site/docs/setup.md b/site/docs/setup.md index 878a7c9..d67b211 100644 --- a/site/docs/setup.md +++ b/site/docs/setup.md @@ -152,6 +152,11 @@ Run `make help` for the full list: | `make test-backend` | Backend tests only (591 tests) | | `make test-frontend` | Frontend tests only (182 tests) | | `make test-docs` | Docs build check | +| **Linting** | | +| `make lint` | Run all linters (ruff + ESLint) | +| `make lint-backend` | Lint backend with ruff | +| `make lint-frontend` | Lint frontend with ESLint | +| `make lint-fix` | Auto-fix linting issues | | **Other** | | | `make check` | Type-check backend + frontend | | `make docs-dev` | Preview docs locally |