diff --git a/.agents/skills/recheck-after-submit/SKILL.md b/.agents/skills/recheck-after-submit/SKILL.md new file mode 100644 index 0000000..0636115 --- /dev/null +++ b/.agents/skills/recheck-after-submit/SKILL.md @@ -0,0 +1,16 @@ +--- +name: recheck-after-submit +description: "Recheck the project after submitting or completing code changes. Use after implementation to run pylint, ESLint, Vite build, and pytest, then fix failures." +--- + +# Recheck After Submit + +Run this workflow after implementing code changes and before reporting the work as complete. + +## Procedure + +1. Inspect the repository scripts and tracked files to identify the project's existing ESLint and Vite build commands. +2. Run `pylint $(git ls-files '*.py')` for tracked Python files. +3. Run the project's ESLint command and Vite production build. +4. Run the relevant `pytest` suite, expanding to all tests when practical. +5. Fix failures caused by the changes, then rerun the checks that failed. Do not report success for checks that were skipped or failed; state any unavailable checks explicitly. diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 521969c..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,3 +0,0 @@ -- After submit code: - - Run `pylint $(git ls-files '*.py')`, `eslint` and `vite build` make sure everything pass. - - Fix all `pytest` make sure everything works. diff --git a/app/core/errors.py b/app/core/errors.py index 8d738e1..6239027 100644 --- a/app/core/errors.py +++ b/app/core/errors.py @@ -63,11 +63,13 @@ class ErrorCode(StrEnum): AI_PROVIDER_FAILED = "AI_PROVIDER_FAILED" AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE = "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE" AI_CHAT_CONVERSATION_NOT_FOUND = "AI_CHAT_CONVERSATION_NOT_FOUND" + AI_CHAT_MESSAGE_NOT_FOUND = "AI_CHAT_MESSAGE_NOT_FOUND" AI_CHAT_MODEL_UNAVAILABLE = "AI_CHAT_MODEL_UNAVAILABLE" + AI_CHAT_SKILL_UNAVAILABLE = "AI_CHAT_SKILL_UNAVAILABLE" AI_CHAT_LIMIT_EXCEEDED = "AI_CHAT_LIMIT_EXCEEDED" AI_CHAT_TOOL_LIMIT_REACHED = "AI_CHAT_TOOL_LIMIT_REACHED" AI_CHAT_EMPTY_RESPONSE = "AI_CHAT_EMPTY_RESPONSE" - AI_COMMAND_APPROVAL_NOT_FOUND = "AI_COMMAND_APPROVAL_NOT_FOUND" + AI_APPROVAL_NOT_FOUND = "AI_APPROVAL_NOT_FOUND" AI_CHANGE_PROPOSAL_NOT_FOUND = "AI_CHANGE_PROPOSAL_NOT_FOUND" AI_CHANGE_PROPOSAL_NOT_PENDING = "AI_CHANGE_PROPOSAL_NOT_PENDING" AI_CHANGE_PROPOSAL_STALE = "AI_CHANGE_PROPOSAL_STALE" diff --git a/app/core/i18n.py b/app/core/i18n.py index 957b435..4a9872b 100644 --- a/app/core/i18n.py +++ b/app/core/i18n.py @@ -54,7 +54,11 @@ "Configure AI credential encryption to use providers" ), "AI_CHAT_CONVERSATION_NOT_FOUND": "Chat conversation not found", + "AI_CHAT_MESSAGE_NOT_FOUND": ( + "That message is no longer available to edit and resend" + ), "AI_CHAT_MODEL_UNAVAILABLE": "The selected model is not available for this provider", + "AI_CHAT_SKILL_UNAVAILABLE": "The selected skill is no longer available", "AI_CHAT_LIMIT_EXCEEDED": ( "The assistant reached a safety limit for this reply. Send another message to continue" ), @@ -65,7 +69,7 @@ "AI_CHAT_EMPTY_RESPONSE": ( "The model returned an empty response. Send another message to retry" ), - "AI_COMMAND_APPROVAL_NOT_FOUND": "Command approval expired or is no longer available", + "AI_APPROVAL_NOT_FOUND": "This approval expired or is no longer available", "AI_CHANGE_PROPOSAL_NOT_FOUND": "Change proposal not found", "AI_CHANGE_PROPOSAL_NOT_PENDING": "Change proposal is no longer pending", "AI_CHANGE_PROPOSAL_STALE": "The file changed after the proposal was created", @@ -131,7 +135,11 @@ "Hãy cấu hình khóa mã hóa trước khi dùng AI provider" ), "AI_CHAT_CONVERSATION_NOT_FOUND": "Không tìm thấy cuộc trò chuyện", + "AI_CHAT_MESSAGE_NOT_FOUND": ( + "Tin nhắn này không còn để sửa và gửi lại" + ), "AI_CHAT_MODEL_UNAVAILABLE": "Model đã chọn không khả dụng với provider này", + "AI_CHAT_SKILL_UNAVAILABLE": "Skill đã chọn không còn khả dụng", "AI_CHAT_LIMIT_EXCEEDED": ( "Trợ lý đã đạt giới hạn an toàn cho phản hồi này. Hãy gửi thêm tin nhắn để tiếp tục" ), @@ -140,7 +148,7 @@ "Hãy gửi thêm tin nhắn để tiếp tục" ), "AI_CHAT_EMPTY_RESPONSE": ("Model trả về nội dung rỗng. Hãy gửi lại tin nhắn để thử lại"), - "AI_COMMAND_APPROVAL_NOT_FOUND": "Yêu cầu duyệt lệnh đã hết hạn hoặc không còn khả dụng", + "AI_APPROVAL_NOT_FOUND": "Yêu cầu duyệt đã hết hạn hoặc không còn khả dụng", "AI_CHANGE_PROPOSAL_NOT_FOUND": "Không tìm thấy đề xuất thay đổi", "AI_CHANGE_PROPOSAL_NOT_PENDING": "Đề xuất thay đổi không còn ở trạng thái chờ duyệt", "AI_CHANGE_PROPOSAL_STALE": "Tệp đã thay đổi sau khi đề xuất được tạo", diff --git a/app/main.py b/app/main.py index 2cc6a0a..f4c6c7a 100644 --- a/app/main.py +++ b/app/main.py @@ -43,6 +43,7 @@ def _apply_lightweight_migrations() -> None: tables = set(inspect(engine).get_table_names()) if "ai_chat_messages" in tables: _add_column_if_missing("ai_chat_messages", "thinking", "TEXT NOT NULL DEFAULT ''") + _add_column_if_missing("ai_chat_messages", "error", "TEXT NOT NULL DEFAULT ''") if "ai_change_proposals" in tables: _add_column_if_missing( "ai_change_proposals", "change_type", "VARCHAR(16) NOT NULL DEFAULT 'modify'" diff --git a/app/modules/ai_changes/service.py b/app/modules/ai_changes/service.py index 78beb22..a38b207 100644 --- a/app/modules/ai_changes/service.py +++ b/app/modules/ai_changes/service.py @@ -1,7 +1,8 @@ import difflib import hashlib +from datetime import datetime -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.orm import Session from app.core.errors import ErrorCode, api_error @@ -253,6 +254,22 @@ def _persist( return proposal_read(proposal) +def delete_proposals_since(db: Session, conversation_id: str, since: datetime) -> int: + """Delete proposals created at or after ``since`` in a conversation. + + Used when earlier chat turns are replaced (edit and resend) so proposals + produced by discarded turns do not linger in the conversation. + """ + result = db.execute( + delete(ChangeProposal).where( + ChangeProposal.conversation_id == conversation_id, + ChangeProposal.created_at >= since, + ) + ) + db.commit() + return result.rowcount or 0 + + def list_proposals( db: Session, user: User, project_id: int, conversation_id: str ) -> list[ChangeProposalRead]: diff --git a/app/modules/ai_chat/approvals.py b/app/modules/ai_chat/approvals.py index 68fc473..cb4ad13 100644 --- a/app/modules/ai_chat/approvals.py +++ b/app/modules/ai_chat/approvals.py @@ -15,22 +15,24 @@ class PendingApproval: approval_id: str user_id: int project_id: int - command: str + kind: str + detail: str decision: asyncio.Future[bool] _pending: dict[str, PendingApproval] = {} -def create_approval(user_id: int, project_id: int, command: str) -> PendingApproval: +def create_approval(user_id: int, project_id: int, kind: str, detail: str) -> PendingApproval: if len(_pending) >= MAX_PENDING_APPROVALS: - raise ApprovalCapacityError("too many pending command approvals") + raise ApprovalCapacityError("too many pending approvals") approval_id = uuid4().hex pending = PendingApproval( approval_id=approval_id, user_id=user_id, project_id=project_id, - command=command, + kind=kind, + detail=detail, decision=asyncio.get_running_loop().create_future(), ) _pending[approval_id] = pending diff --git a/app/modules/ai_chat/instructions.py b/app/modules/ai_chat/instructions.py new file mode 100644 index 0000000..a265b1b --- /dev/null +++ b/app/modules/ai_chat/instructions.py @@ -0,0 +1,58 @@ +"""User and project agent instructions (``AGENTS.md``) for chat sessions. + +The project tools refuse paths outside the project, so the agent cannot read the +user-level file itself. The server therefore loads both files and injects them +into the system prompt when a chat turn starts. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +MAX_INSTRUCTIONS_CHARS = 24_000 +MAX_INSTRUCTIONS_FILE_CHARS = 16_000 +PROJECT_INSTRUCTIONS_NAME = "AGENTS.md" +USER_INSTRUCTIONS_PARTS = (".config", "agents", PROJECT_INSTRUCTIONS_NAME) +# Only these display labels reach the provider: absolute server paths would leak +# the operator's home directory and worktree layout to a third-party AI vendor. +USER_INSTRUCTIONS_LABEL = "~/.config/agents/" + PROJECT_INSTRUCTIONS_NAME + +logger = logging.getLogger(__name__) + + +def user_instructions_path() -> Path: + """Return the user-level instructions path (``~/.config/agents/AGENTS.md``).""" + return Path.home().joinpath(*USER_INSTRUCTIONS_PARTS) + + +def load_agent_instructions(project_path: Path | None) -> str: + """Return the user and project instructions formatted for the system prompt. + + Missing or unreadable files are skipped. Each file is read up to + ``MAX_INSTRUCTIONS_FILE_CHARS`` and the combined result is capped at + ``MAX_INSTRUCTIONS_CHARS`` so large files cannot crowd out the conversation. + """ + sources = [("User", user_instructions_path(), USER_INSTRUCTIONS_LABEL)] + if project_path is not None: + sources.append( + ("Project", project_path / PROJECT_INSTRUCTIONS_NAME, PROJECT_INSTRUCTIONS_NAME) + ) + + sections: list[str] = [] + for label, path, display in sources: + content = _read_optional(path, MAX_INSTRUCTIONS_FILE_CHARS) + if content: + sections.append(f"# {label} instructions ({display})\n\n{content}") + return "\n\n".join(sections)[:MAX_INSTRUCTIONS_CHARS] + + +def _read_optional(path: Path, limit: int) -> str: + try: + if not path.is_file(): + return "" + with path.open("r", encoding="utf-8", errors="replace") as handle: + return handle.read(limit).strip() + except OSError: + logger.warning("Could not read agent instructions at %s", path) + return "" diff --git a/app/modules/ai_chat/models.py b/app/modules/ai_chat/models.py index bebc0bd..6af9563 100644 --- a/app/modules/ai_chat/models.py +++ b/app/modules/ai_chat/models.py @@ -59,6 +59,7 @@ class ChatMessage(Base): ) content: Mapped[str] = mapped_column(Text) thinking: Mapped[str] = mapped_column(Text, default="") + error: Mapped[str] = mapped_column(Text, default="") status: Mapped[MessageStatus] = mapped_column( Enum( MessageStatus, diff --git a/app/modules/ai_chat/router.py b/app/modules/ai_chat/router.py index 327d4f3..cc521b7 100644 --- a/app/modules/ai_chat/router.py +++ b/app/modules/ai_chat/router.py @@ -6,7 +6,7 @@ from app.core.deps import DbDep, SettingsDep from app.core.errors import ErrorCode, api_error, error_responses from app.modules.ai_changes.router import router as ai_changes_router -from app.modules.ai_chat import approvals, service +from app.modules.ai_chat import approvals, service, skills from app.modules.ai_chat.context import ProjectContext from app.modules.ai_chat.schemas import ( ChatTurnRequest, @@ -14,6 +14,8 @@ CommandPermissionRead, ConversationDetail, ConversationListResponse, + SkillListResponse, + SkillRead, ) from app.modules.ai_chat.streaming import stream_chat_turn from app.modules.ai_providers import service as provider_service @@ -49,11 +51,11 @@ def update_command_permission( @router.post( - "/commands/{approval_id}", + "/approvals/{approval_id}", status_code=status.HTTP_204_NO_CONTENT, responses=error_responses(401, 403, 404), ) -async def decide_command_approval( +async def decide_approval( project_id: int, approval_id: str, payload: CommandApprovalDecision, @@ -68,7 +70,22 @@ async def decide_command_approval( project_id, payload.approved, ): - raise api_error(ErrorCode.AI_COMMAND_APPROVAL_NOT_FOUND, status_code=404) + raise api_error(ErrorCode.AI_APPROVAL_NOT_FOUND, status_code=404) + + +@router.get("/skills", response_model=SkillListResponse, responses=error_responses(401, 403, 404)) +def list_skills( + project_id: int, + current_user: CurrentUser, + db: DbDep, +) -> SkillListResponse: + project = project_service.get_project(db, current_user, project_id) + return SkillListResponse( + skills=[ + SkillRead(slug=skill.slug, name=skill.name, description=skill.description) + for skill in skills.list_skills(Path(project.path)) + ] + ) @router.get("/conversations", response_model=ConversationListResponse, responses=_CHAT_ERRORS) @@ -115,6 +132,12 @@ async def stream_message( models = await provider_service.list_models(db, current_user.id, payload.provider_id, settings) if not any(model.id == payload.model_id for model in models): raise api_error(ErrorCode.AI_CHAT_MODEL_UNAVAILABLE, status_code=422) + skill_body: str | None = None + if payload.skill is not None: + skill_body = skills.load_skill_body(payload.skill, Path(project.path)) + if skill_body is None: + raise api_error(ErrorCode.AI_CHAT_SKILL_UNAVAILABLE, status_code=422) + user_content = payload.message or f"/{payload.skill}" _, api_key, adapter = provider_service.chat_credentials( db, current_user.id, payload.provider_id, settings ) @@ -132,7 +155,8 @@ async def stream_message( payload.conversation_id, payload.provider_id, payload.model_id, - payload.message, + user_content, + payload.edit_user_message_id, ) return StreamingResponse( stream_chat_turn( @@ -147,9 +171,10 @@ async def stream_message( conversation_id=conversation.id, conversation_title=conversation.title, user_message_id=user_message.id, - user_content=payload.message, + user_content=user_content, assistant_message=assistant_message, history=history, + skill_body=skill_body, ), media_type="application/x-ndjson", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, diff --git a/app/modules/ai_chat/schemas.py b/app/modules/ai_chat/schemas.py index ef2cdd5..edf1037 100644 --- a/app/modules/ai_chat/schemas.py +++ b/app/modules/ai_chat/schemas.py @@ -1,7 +1,7 @@ from datetime import datetime from enum import StrEnum -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from app.modules.ai_chat.models import MessageRole, MessageStatus @@ -30,7 +30,29 @@ class ChatTurnRequest(BaseModel): conversation_id: str | None = Field(default=None, min_length=1, max_length=36) provider_id: int = Field(gt=0) model_id: str = Field(min_length=1, max_length=255) - message: str = Field(min_length=1, max_length=16000) + message: str = Field(default="", max_length=16000) + skill: str | None = Field(default=None, min_length=1, max_length=64) + edit_user_message_id: int | None = Field(default=None, gt=0) + + @model_validator(mode="after") + def _require_conversation_for_edit(self) -> "ChatTurnRequest": + if self.edit_user_message_id is not None and self.conversation_id is None: + raise ValueError("edit_user_message_id requires conversation_id") + if not self.message and not self.skill: + raise ValueError("message or skill is required") + return self + + +class SkillRead(BaseModel): + model_config = ConfigDict(extra="forbid") + + slug: str + name: str + description: str + + +class SkillListResponse(BaseModel): + skills: list[SkillRead] class ConversationRead(BaseModel): @@ -54,6 +76,7 @@ class ChatMessageRead(BaseModel): role: MessageRole content: str thinking: str + error: str status: MessageStatus created_at: datetime diff --git a/app/modules/ai_chat/service.py b/app/modules/ai_chat/service.py index 757eb97..f8e5ee5 100644 --- a/app/modules/ai_chat/service.py +++ b/app/modules/ai_chat/service.py @@ -1,9 +1,11 @@ import logging -from sqlalchemy import desc, select + +from sqlalchemy import delete, desc, select from sqlalchemy.orm import Session from app.core import user_settings from app.core.errors import ErrorCode, api_error +from app.modules.ai_changes import service as change_service from app.modules.ai_chat.models import ChatMessage, Conversation, MessageRole, MessageStatus from app.modules.ai_chat.schemas import ( ChatMessageRead, @@ -79,6 +81,7 @@ def prepare_turn( provider_id: int, model_id: str, user_content: str, + edit_user_message_id: int | None = None, ) -> tuple[Project, Conversation, ChatMessage, ChatMessage, list[ProviderMessage]]: project = project_service.get_project(db, user, project_id) if conversation_id is None: @@ -93,6 +96,8 @@ def prepare_turn( db.flush() else: conversation = _get_conversation(db, user.id, project_id, conversation_id) + if edit_user_message_id is not None: + _truncate_for_edit(db, conversation, edit_user_message_id) previous = list( db.scalars( @@ -144,13 +149,41 @@ def finish_assistant( content: str, status: MessageStatus, thinking: str = "", + error: str = "", ) -> None: assistant_message.content = content assistant_message.thinking = thinking + assistant_message.error = error assistant_message.status = status db.commit() +def _truncate_for_edit( + db: Session, conversation: Conversation, edit_user_message_id: int +) -> None: + """Drop a user message and everything after it so it can be re-sent. + + Editing replaces an earlier turn, so every later message (and any change + proposals they produced) is discarded before the new turn is prepared. + """ + target = db.scalar( + select(ChatMessage).where( + ChatMessage.id == edit_user_message_id, + ChatMessage.conversation_id == conversation.id, + ) + ) + if target is None or target.role is not MessageRole.USER: + raise api_error(ErrorCode.AI_CHAT_MESSAGE_NOT_FOUND, status_code=404) + db.execute( + delete(ChatMessage).where( + ChatMessage.conversation_id == conversation.id, + ChatMessage.id >= edit_user_message_id, + ) + ) + # Commits the message deletion together with the proposal cleanup. + change_service.delete_proposals_since(db, conversation.id, target.created_at) + + def _get_conversation( db: Session, user_id: int, project_id: int, conversation_id: str ) -> Conversation: diff --git a/app/modules/ai_chat/skills.py b/app/modules/ai_chat/skills.py new file mode 100644 index 0000000..b217e16 --- /dev/null +++ b/app/modules/ai_chat/skills.py @@ -0,0 +1,250 @@ +"""Agent Skills for chat sessions. + +Skills live under ``.agents/skills//SKILL.md`` in two places: the project +(``/.agents/skills``) and the user home (``~/.agents/skills``). Project +skills shadow user skills with the same slug. Each ``SKILL.md`` starts with a +YAML frontmatter block (``name`` and ``description``) followed by the skill body. +The chat UI lists these slugs as slash commands and the server injects the +selected skill body into the system prompt for that turn. + +A skill may span several files: ``SKILL.md`` can reference supporting resources +in the same folder (for example ``reference.md`` or ``examples/``). The project +tools refuse paths outside the project, so the agent reads those extra parts +through the chat ``read_file`` tool, which falls back to the skills directories +(read-only) via :func:`read_skill_file`. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from fastapi import status + +from app.core.errors import ErrorCode, api_error + +SKILL_FILE_NAME = "SKILL.md" +SKILLS_PARTS = (".agents", "skills") +MAX_SKILL_FILE_CHARS = 24_000 +MAX_SKILL_RESOURCE_CHARS = 24_000 +MAX_SKILL_RESOURCE_BYTES = 512_000 +MAX_DESCRIPTION_CHARS = 1_000 +_SLUG_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_RESERVED_SLUGS = {".", ".."} + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Skill: + """A single discoverable skill (metadata only).""" + + slug: str + name: str + description: str + + +@dataclass(frozen=True) +class SkillFile: + """A supporting file read from a skill folder (read-only).""" + + path: str + content: str + truncated: bool + + +def skills_root() -> Path: + """Return the user-level skills directory (``~/.agents/skills``).""" + return Path.home().joinpath(*SKILLS_PARTS) + + +def project_skills_root(project_path: Path) -> Path: + """Return the project-level skills directory (``/.agents/skills``).""" + return project_path.joinpath(*SKILLS_PARTS) + + +def _skill_roots(project_path: Path | None) -> list[Path]: + """Return the skills directories in priority order: project, then user.""" + roots: list[Path] = [] + if project_path is not None: + roots.append(project_skills_root(project_path)) + roots.append(skills_root()) + return roots + + +def list_skills(project_path: Path | None = None) -> list[Skill]: + """Return the available skills sorted by slug. + + Skills are read from the project (``/.agents/skills``) and the user + (``~/.agents/skills``) directories; a project skill shadows a user skill with + the same slug. Missing or unreadable directories are skipped, and directories + without a readable ``SKILL.md`` are ignored. + """ + found: dict[str, Skill] = {} + for root in _skill_roots(project_path): + for skill in _skills_in_root(root): + found.setdefault(skill.slug, skill) + return sorted(found.values(), key=lambda skill: skill.slug.lower()) + + +def _skills_in_root(root: Path) -> list[Skill]: + try: + entries = sorted(root.iterdir(), key=lambda entry: entry.name.lower()) + except OSError: + return [] + + skills: list[Skill] = [] + for entry in entries: + if not entry.is_dir() or not _is_valid_slug(entry.name): + continue + skill = _read_skill(entry) + if skill is not None: + skills.append(skill) + return skills + + +def load_skill_body(slug: str, project_path: Path | None = None) -> str | None: + """Return the skill body (frontmatter stripped) for ``slug``. + + The project directory is checked before the user directory. Returns ``None`` + when the slug is invalid or the skill is missing, so the caller can reject + the request instead of silently ignoring the skill. + """ + if not _is_valid_slug(slug): + return None + for root in _skill_roots(project_path): + text = _read_text(root / slug / SKILL_FILE_NAME, MAX_SKILL_FILE_CHARS) + if text is None: + continue + _, body = parse_frontmatter(text) + return body or text + return None + + +def read_skill_file(path: str, project_path: Path | None = None) -> SkillFile: + """Read a supporting file of a skill so the agent can follow multi-part skills. + + ``path`` is raised by the agent and is relative to a skills root + (``/.agents/skills`` or ``~/.agents/skills``), for example + ``my-skill/reference.md``. Paths that escape a skills root, binary files, and + oversized files are rejected so the tool can never read arbitrary files from + the project or the operator's home directory. + """ + candidate = _validate_skill_path(path) + escaped = False + for root in _skill_roots(project_path): + resolved_root = root.resolve() + target = (resolved_root / candidate).resolve() + if not target.is_relative_to(resolved_root): + escaped = True + continue + if target.is_file(): + return _read_skill_target(resolved_root, target) + if escaped: + raise api_error(ErrorCode.FILE_PATH_INVALID, status_code=status.HTTP_400_BAD_REQUEST) + raise api_error(ErrorCode.FILE_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND) + + +def _validate_skill_path(path: str) -> str: + """Reject an empty, absolute, or parent-escaping skill path.""" + candidate = path.strip() + parts = PurePosixPath(candidate).parts + if not candidate or candidate.startswith(("/", "~", "-")) or ".." in parts: + raise api_error(ErrorCode.FILE_PATH_INVALID, status_code=status.HTTP_400_BAD_REQUEST) + return candidate + + +def _read_skill_target(root: Path, target: Path) -> SkillFile: + """Read ``target`` (already resolved inside ``root``) as a UTF-8 skill file.""" + try: + if target.stat().st_size > MAX_SKILL_RESOURCE_BYTES: + raise api_error(ErrorCode.FILE_TOO_LARGE, status_code=status.HTTP_400_BAD_REQUEST) + data = target.read_bytes() + except OSError as exc: + raise api_error(ErrorCode.FILE_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND) from exc + + if len(data) > MAX_SKILL_RESOURCE_BYTES: + raise api_error(ErrorCode.FILE_TOO_LARGE, status_code=status.HTTP_400_BAD_REQUEST) + if b"\x00" in data: + raise api_error(ErrorCode.FILE_BINARY, status_code=status.HTTP_400_BAD_REQUEST) + try: + text = data.decode("utf-8") + except UnicodeDecodeError as exc: + raise api_error(ErrorCode.FILE_BINARY, status_code=status.HTTP_400_BAD_REQUEST) from exc + + return SkillFile( + path=target.relative_to(root).as_posix(), + content=text[:MAX_SKILL_RESOURCE_CHARS], + truncated=len(text) > MAX_SKILL_RESOURCE_CHARS, + ) + + +def _read_skill(directory: Path) -> Skill | None: + text = _read_text(directory / SKILL_FILE_NAME, MAX_SKILL_FILE_CHARS) + if text is None: + return None + meta, _ = parse_frontmatter(text) + name = meta.get("name") or directory.name + description = meta.get("description", "")[:MAX_DESCRIPTION_CHARS] + return Skill(slug=directory.name, name=name, description=description) + + +def _is_valid_slug(slug: str) -> bool: + return slug not in _RESERVED_SLUGS and bool(_SLUG_PATTERN.match(slug)) + + +def _read_text(path: Path, limit: int) -> str | None: + try: + if not path.is_file(): + return None + with path.open("r", encoding="utf-8", errors="replace") as handle: + return handle.read(limit).strip() + except OSError: + logger.warning("Could not read skill file at %s", path) + return None + + +def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: + """Split a ``SKILL.md`` document into its frontmatter map and body. + + Implements just enough YAML for skill frontmatter: top-level ``key: value`` + pairs where values may be wrapped onto following indented lines (folded into + a single space). Documents without a leading ``---`` block have no metadata. + """ + if not text.startswith("---"): + return {}, text + lines = text.splitlines() + if lines[0].strip() != "---": + return {}, text + end = next( + (index for index in range(1, len(lines)) if lines[index].strip() == "---"), + None, + ) + if end is None: + return {}, text + + meta: dict[str, str] = {} + key: str | None = None + buffer: list[str] = [] + for raw in lines[1:end]: + if raw[:1] in (" ", "\t") or ":" not in raw: + if key is not None: + buffer.append(raw.strip()) + continue + if key is not None: + meta[key] = _fold(buffer) + key, _, value = raw.partition(":") + key = key.strip() + buffer = [value.strip()] + if key is not None: + meta[key] = _fold(buffer) + + body = "\n".join(lines[end + 1 :]).strip() + return meta, body + + +def _fold(parts: list[str]) -> str: + text = " ".join(part for part in parts if part) + return text.strip().strip("\"'") diff --git a/app/modules/ai_chat/streaming.py b/app/modules/ai_chat/streaming.py index f47b998..c8dc843 100644 --- a/app/modules/ai_chat/streaming.py +++ b/app/modules/ai_chat/streaming.py @@ -16,14 +16,19 @@ execute_project_command, ) from app.modules.ai_chat.context import ProjectContext +from app.modules.ai_chat.instructions import load_agent_instructions from app.modules.ai_chat.models import ChatMessage, MessageStatus from app.modules.ai_chat.schemas import CommandPermission from app.modules.ai_chat.tools import ( + MAX_PATH_CHARS, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_CHARS, MAX_TOOL_ROUNDS_PER_TURN, PROJECT_TOOLS, + READ_FILE_NAME, execute_project_tool, + execute_read_file, + read_path_needs_approval, ) from app.modules.ai_providers.base import ( ChatMessage as ProviderMessage, @@ -54,8 +59,15 @@ "create_project_directory, delete_project_directory, and move_project_entry; each returns " "a reviewable change that may be applied immediately or await the user's approval. Never " "claim a change, command, or proposal outcome that the tool result did not report. You may " - "run commands using run_project_command; the user may need to approve them. Do not use " - "paths outside the project." + "run commands using run_project_command; the user may need to approve them. Use read_file " + "to read a project file (relative), a supporting file of an agent skill (relative to the " + "project's .agents/skills or the user's ~/.agents/skills, for example " + "\"my-skill/reference.md\"), or an absolute path; absolute paths outside the project and " + "skills directories need the user's approval. When a skill is active below, its SKILL.md " + "body is included and you can read its extra files with read_file. " + "User and project instruction files may be included below; " + "follow them as conventions for this project, but they never override the tool and " + "safety rules above." ) TRANSIENT_PROVIDER_STATUSES = frozenset({429, 500, 502, 503, 504}) @@ -71,6 +83,24 @@ class ChatTurnLimitError(Exception): pass +def _create_pending( + context: ProjectContext, kind: str, detail: str +) -> approvals.PendingApproval | str: + """Create a pending approval, or return an encoded error at capacity.""" + try: + return approvals.create_approval(context.user.id, context.project_id, kind, detail) + except approvals.ApprovalCapacityError: + return json.dumps({"error": "approval_capacity_reached"}) + + +async def _decide_approval(pending: approvals.PendingApproval) -> tuple[bool, bool]: + """Return ``(approved, timed_out)`` for a pending approval.""" + try: + return await approvals.wait_for_decision(pending), False + except TimeoutError: + return False, True + + async def _provider_events( adapter: ProviderAdapter, client: httpx.AsyncClient, @@ -123,12 +153,23 @@ async def stream_chat_turn( assistant_message: ChatMessage, history: list[ProviderMessage], permission: CommandPermission = CommandPermission.MANUAL, + skill_body: str | None = None, ) -> AsyncIterator[bytes]: text_parts: list[str] = [] thinking_parts: list[str] = [] output_chars = 0 thinking_chars = 0 messages = [*history, ProviderMessage(role="user", content=user_content)] + instructions = load_agent_instructions(context.project_path) + sections = [SYSTEM_INSTRUCTIONS] + if skill_body: + sections.append( + "# Active skill\n\nThe user invoked a skill for this turn. Apply the " + f"following skill instructions to the request below.\n\n{skill_body}" + ) + if instructions: + sections.append(instructions) + base_system = "\n\n".join(sections) try: yield _event( { @@ -149,9 +190,9 @@ async def stream_chat_turn( final_round = round_index + 1 >= MAX_TOOL_ROUNDS_PER_TURN round_tools = () if final_round else PROJECT_TOOLS round_system = ( - f"{SYSTEM_INSTRUCTIONS}\n\n{FINAL_ANSWER_INSTRUCTION}" + f"{base_system}\n\n{FINAL_ANSWER_INSTRUCTION}" if final_round - else SYSTEM_INSTRUCTIONS + else base_system ) if final_round: yield _notice_event(ErrorCode.AI_CHAT_TOOL_LIMIT_REACHED) @@ -241,48 +282,102 @@ async def stream_chat_turn( if command is None or context.project_path is None: result = json.dumps({"error": "invalid_command_arguments"}) else: - result = "" + result: str | None = None if command_needs_approval(command, permission): + pending = _create_pending(context, "command", command) + if isinstance(pending, str): + result = pending + pending = None + else: + pending = None + if pending is not None: try: - pending = approvals.create_approval( - context.user.id, context.project_id, command + yield _event( + { + "type": "command_approval", + "approval_id": pending.approval_id, + "command": command, + "reason": ( + "manual_permission" + if permission is CommandPermission.MANUAL + else "command_may_change_state" + ), + } ) - except approvals.ApprovalCapacityError: - result = json.dumps({"error": "approval_capacity_reached"}) - pending = None - if pending is not None: - try: + approved, timed_out = await _decide_approval(pending) + if timed_out: yield _event( { - "type": "command_approval", + "type": "command_approval_expired", "approval_id": pending.approval_id, - "command": command, - "reason": ( - "manual_permission" - if permission is CommandPermission.MANUAL - else "command_may_change_state" - ), } ) - try: - approved = await approvals.wait_for_decision(pending) - except TimeoutError: - result = json.dumps({"error": "approval_timed_out"}) - approved = False - yield _event( - { - "type": "command_approval_expired", - "approval_id": pending.approval_id, - } - ) - if not approved and not result: - result = json.dumps({"error": "command_cancelled"}) - finally: - approvals.discard_approval(pending) - if not result: + result = ( + await execute_project_command( + context.project_path, command + ) + if approved + else json.dumps( + { + "error": "approval_timed_out" + if timed_out + else "command_cancelled" + } + ) + ) + finally: + approvals.discard_approval(pending) + if result is None: result = await execute_project_command( context.project_path, command ) + elif call.name == READ_FILE_NAME: + read_path = call.arguments.get("path") + if ( + not isinstance(read_path, str) + or not read_path.strip() + or len(read_path) > MAX_PATH_CHARS + ): + result = json.dumps({"error": "invalid_arguments"}) + elif ( + not read_path_needs_approval(context, read_path) + or permission is CommandPermission.ALLOW_ALL + ): + result = execute_read_file(context, read_path) + else: + pending = _create_pending(context, "read", read_path) + if isinstance(pending, str): + result = pending + else: + try: + yield _event( + { + "type": "read_approval", + "approval_id": pending.approval_id, + "path": read_path, + } + ) + approved, timed_out = await _decide_approval(pending) + if timed_out: + yield _event( + { + "type": "read_approval_expired", + "approval_id": pending.approval_id, + } + ) + result = ( + execute_read_file(context, read_path) + if approved + else json.dumps( + { + "error": "approval_timed_out" + if timed_out + else "read_cancelled" + } + ) + ) + finally: + approvals.discard_approval(pending) else: result = execute_project_tool(context, call) proposal_payload = _proposal_payload(result) @@ -317,6 +412,7 @@ async def stream_chat_turn( "", MessageStatus.FAILED, thinking="".join(thinking_parts), + error=_error_message(ErrorCode.AI_CHAT_EMPTY_RESPONSE), ) yield _error_event(ErrorCode.AI_CHAT_EMPTY_RESPONSE, assistant_message.id) return @@ -351,15 +447,17 @@ async def stream_chat_turn( content, MessageStatus.FAILED, thinking="".join(thinking_parts), + error=_error_message(ErrorCode.AI_CHAT_LIMIT_EXCEEDED), ) yield _error_event(ErrorCode.AI_CHAT_LIMIT_EXCEEDED, assistant_message.id) except ProviderAPIError as exc: + detail = exc.diagnostic or str(exc) logger.warning( "AI chat provider request failed conversation_id=%s provider=%s status=%s detail=%s", conversation_id, adapter.kind, exc.status_code, - exc.diagnostic or str(exc), + detail, ) content = "".join(text_parts) service.finish_assistant( @@ -368,11 +466,12 @@ async def stream_chat_turn( content, MessageStatus.FAILED, thinking="".join(thinking_parts), + error=_error_message(ErrorCode.AI_PROVIDER_FAILED, detail), ) yield _error_event( ErrorCode.AI_PROVIDER_FAILED, assistant_message.id, - detail=exc.diagnostic or str(exc), + detail=detail, ) except asyncio.CancelledError: service.finish_assistant( @@ -399,12 +498,16 @@ async def stream_chat_turn( raise +def _error_message(code: ErrorCode, detail: str | None = None) -> str: + return detail or translate(code.value) + + def _error_event(code: ErrorCode, message_id: int, detail: str | None = None) -> bytes: return _event( { "type": "error", "code": code.value, - "message": detail or translate(code.value), + "message": _error_message(code, detail), "assistant_message_id": message_id, } ) diff --git a/app/modules/ai_chat/tools.py b/app/modules/ai_chat/tools.py index eb2d482..b0ec677 100644 --- a/app/modules/ai_chat/tools.py +++ b/app/modules/ai_chat/tools.py @@ -1,8 +1,11 @@ import json +from pathlib import Path from fastapi import HTTPException +from app.core.errors import ErrorCode from app.modules.ai_changes.schemas import ChangeProposalRead +from app.modules.ai_chat import skills from app.modules.ai_chat.commands import RUN_PROJECT_COMMAND from app.modules.ai_chat.context import ProjectContext from app.modules.ai_providers.base import ProviderTool, ToolCall @@ -12,6 +15,9 @@ MAX_TOOL_ROUNDS_PER_TURN = 8 MAX_PATH_CHARS = 4096 MAX_SEARCH_QUERY_CHARS = 500 +MAX_READ_CHARS = 24_000 +MAX_READ_BYTES = 512_000 +READ_FILE_NAME = "read_file" _PATH_ARGUMENTS = { "type": "object", @@ -43,8 +49,14 @@ }, ), ProviderTool( - name="read_project_file", - description="Read a UTF-8 text file using a project-relative path.", + name=READ_FILE_NAME, + description=( + "Read a UTF-8 text file. Use a project-relative path for project files " + "(for example \"src/app.py\"), a path relative to a skills directory for a " + "skill's supporting files (for example \"my-skill/reference.md\"), or an " + "absolute path. Absolute paths outside the project and skills directories " + "require the user's approval." + ), parameters=_PATH_ARGUMENTS, ), ProviderTool( @@ -198,16 +210,111 @@ def _read(context: ProjectContext, name: str, arguments: dict[str, object]) -> s path = arguments.get("path") if path is not None and (not isinstance(path, str) or len(path) > MAX_PATH_CHARS): return _encode({"error": "invalid_arguments"}) - try: - if name == "list_project_files": + if name == "list_project_files": + try: result = context.list_files(path) - elif name == "read_project_file" and isinstance(path, str) and path: - result = context.read_file(path) - else: - return _encode({"error": "unsupported_tool_or_arguments"}) + except HTTPException as exc: + return _error_result(exc) + return _encode_result(result) + if name != READ_FILE_NAME: + return _encode({"error": "unsupported_tool_or_arguments"}) + if not _is_path(path) or Path(path).is_absolute(): + # Absolute paths are handled by the streaming layer so an out-of-roots + # read can require the user's approval; this entry point never reads them. + return _encode({"error": "invalid_arguments"}) + return execute_read_file(context, path) + + +def read_path_needs_approval(context: ProjectContext, path: str) -> bool: + """Return True when reading ``path`` falls outside the allowed read roots. + + Relative paths always resolve inside the project or the skills directories, + so only absolute paths can require the user's approval. + """ + target = Path(path) + if not target.is_absolute(): + return False + return _allowed_source(context, target.resolve()) is None + + +def execute_read_file(context: ProjectContext, path: str) -> str: + """Read ``path`` after the allowed-roots (and, if needed, approval) check. + + Absolute paths outside the allowed roots are read here only when the caller + already obtained approval (or the permission mode is allow-all). + """ + target = Path(path) + if not target.is_absolute(): + return _read_file(context, path) + resolved = target.resolve() + source = _allowed_source(context, resolved) or "external" + return _read_text_result(resolved, path, source) + + +def _allowed_source(context: ProjectContext, resolved: Path) -> str | None: + """Label the allowed root that contains ``resolved`` (``project``/``skill``).""" + project_path = context.project_path + if project_path is not None: + project_skills = skills.project_skills_root(project_path).resolve() + if resolved.is_relative_to(project_skills): + return "skill" + if resolved.is_relative_to(project_path.resolve()): + return "project" + if resolved.is_relative_to(skills.skills_root().resolve()): + return "skill" + return None + + +def _read_file(context: ProjectContext, path: str) -> str: + """Read a project-relative path, falling back to the skills directories.""" + try: + return _encode_result({**context.read_file(path), "source": "project"}) + except HTTPException as exc: + if _error_code(exc) != ErrorCode.FILE_NOT_FOUND.value: + return _error_result(exc) + try: + skill_file = skills.read_skill_file(path, context.project_path) except HTTPException as exc: return _error_result(exc) - return _encode_result(result) + return _encode_result( + { + "path": skill_file.path, + "content": skill_file.content, + "truncated": skill_file.truncated, + "source": "skill", + } + ) + + +def _read_text_result(target: Path, display: str, source: str) -> str: + """Read ``target`` (any location) as UTF-8 text with size/binary guards.""" + if not target.is_file(): + return _encode({"error": ErrorCode.FILE_NOT_FOUND.value}) + try: + if target.stat().st_size > MAX_READ_BYTES: + return _encode({"error": ErrorCode.FILE_TOO_LARGE.value}) + data = target.read_bytes() + except OSError: + return _encode({"error": ErrorCode.FILE_NOT_FOUND.value}) + + if len(data) > MAX_READ_BYTES: + return _encode({"error": ErrorCode.FILE_TOO_LARGE.value}) + if b"\x00" in data: + return _encode({"error": ErrorCode.FILE_BINARY.value}) + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + return _encode({"error": ErrorCode.FILE_BINARY.value}) + + truncated = len(text) > MAX_READ_CHARS + return _encode_result( + { + "path": display, + "content": text[:MAX_READ_CHARS], + "truncated": truncated, + "source": source, + } + ) def _is_path(value: object) -> bool: @@ -222,9 +329,14 @@ def _encode_result(result: dict[str, object]) -> str: def _error_result(exc: HTTPException) -> str: + code = _error_code(exc) + return _encode({"error": code if code is not None else "proposal_failed"}) + + +def _error_code(exc: HTTPException) -> str | None: detail = exc.detail code = detail.get("code") if isinstance(detail, dict) else None - return _encode({"error": code if isinstance(code, str) else "proposal_failed"}) + return code if isinstance(code, str) else None def _proposal_result(proposal: ChangeProposalRead) -> str: diff --git a/tests/test_ai_changes.py b/tests/test_ai_changes.py index 53895da..4980342 100644 --- a/tests/test_ai_changes.py +++ b/tests/test_ai_changes.py @@ -105,6 +105,44 @@ def test_proposal_is_reviewable_and_does_not_write_before_approval( assert listed[0]["status"] == "pending" +def test_edit_and_resend_discards_proposals_from_replaced_turns( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + path = projects_root / "project" + project_id = _create_project(client, path) + provider_id = _create_provider(client) + (path / "main.py").write_text("print('original')\n", encoding="utf-8") + + conversation_id = _create_proposal(client, project_id, provider_id, monkeypatch)[0] + messages = client.get( + f"/api/v1/projects/{project_id}/ai-chat/conversations/{conversation_id}" + ).json()["messages"] + first_user_id = messages[0]["id"] + + response = client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={ + "conversation_id": conversation_id, + "provider_id": provider_id, + "model_id": "gpt-test", + "message": "Update main.py again", + "edit_user_message_id": first_user_id, + }, + headers=_csrf(client), + ) + assert response.status_code == 200 + + listed = client.get( + f"/api/v1/projects/{project_id}/ai-chat/conversations/{conversation_id}/proposals" + ).json()["proposals"] + assert listed == [] + remaining = client.get( + f"/api/v1/projects/{project_id}/ai-chat/conversations/{conversation_id}" + ).json()["messages"] + assert [message["content"] for message in remaining][0] == "Update main.py again" + + def test_apply_requires_explicit_approval_and_writes_proposed_content( client: TestClient, projects_root, monkeypatch ) -> None: diff --git a/tests/test_ai_chat.py b/tests/test_ai_chat.py index 72b30a5..953c89c 100644 --- a/tests/test_ai_chat.py +++ b/tests/test_ai_chat.py @@ -8,11 +8,12 @@ from httpx import AsyncClient from app.core.errors import ErrorCode +from app.modules.ai_chat import skills from app.modules.ai_chat.context import ProjectContext from app.modules.ai_chat.models import ChatMessage as StoredMessage from app.modules.ai_chat.models import MessageStatus -from app.modules.ai_chat.streaming import stream_chat_turn -from app.modules.ai_chat.tools import MAX_TOOL_ROUNDS_PER_TURN +from app.modules.ai_chat.streaming import ChatTurnLimitError, stream_chat_turn +from app.modules.ai_chat.tools import MAX_TOOL_ROUNDS_PER_TURN, execute_project_tool from app.modules.ai_providers.base import ( ProviderAPIError, ProviderModel, @@ -45,12 +46,65 @@ def _create_provider(client: TestClient) -> int: return response.json()["id"] +def _write_skill( + home: Path, slug: str, *, name: str, description: str, body: str +) -> None: + directory = home / ".agents" / "skills" / slug + directory.mkdir(parents=True, exist_ok=True) + (directory / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n", + encoding="utf-8", + ) + + def _events(response) -> list[dict[str, object]]: assert response.status_code == 200, response.text assert response.headers["content-type"].startswith("application/x-ndjson") return [json.loads(line) for line in response.text.splitlines()] +def _set_permission(client: TestClient, project_id: int, mode: str) -> None: + response = client.put( + f"/api/v1/projects/{project_id}/ai-chat/command-permission", + json={"mode": mode}, + headers=_csrf(client), + ) + assert response.status_code == 200, response.text + + +def _stream_read( + client: TestClient, project_id: int, provider_id: int, path: str, monkeypatch +) -> tuple[list[dict[str, object]], list[str]]: + """Drive one chat turn whose only tool call is ``read_file(path)``.""" + results: list[str] = [] + calls = {"n": 0} + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, _system, messages, _tools, *, session_id=None + ): + calls["n"] += 1 + if calls["n"] == 1: + yield ToolCall("read-1", "read_file", {"path": path}) + else: + results.extend(message.content for message in messages if message.role == "tool") + yield TextDelta("done") + yield TurnComplete("tool_calls" if calls["n"] == 1 else "completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + events = _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Read"}, + headers=_csrf(client), + ) + ) + return events, results + + def test_chat_streams_and_persists_a_resumable_conversation( client: TestClient, projects_root, monkeypatch ) -> None: @@ -68,7 +122,7 @@ async def stream(self, _client, api_key, model, _system, messages, tools, *, ses assert model == "gpt-test" assert {tool.name for tool in tools} == { "list_project_files", - "read_project_file", + "read_file", "search_project_files", "propose_file_change", "create_project_file", @@ -400,6 +454,7 @@ async def stream(self, *_args, **_kwargs): assert events[-1]["type"] == "error" assert events[-1]["code"] == "AI_CHAT_EMPTY_RESPONSE" assert messages[-1]["status"] == "failed" + assert messages[-1]["error"] == events[-1]["message"] def test_chat_provider_failure_is_streamed_and_persisted( @@ -438,56 +493,56 @@ async def fail_stream(self, *_args, **_kwargs): assert events[-1]["code"] == "AI_PROVIDER_FAILED" assert events[-1]["message"] == "model is not available" assert messages[-1]["status"] == "failed" + assert messages[-1]["error"] == "model is not available" assert "provider-secret" not in str(events) assert "provider=openai status=400 detail=model is not available" in caplog.text -def test_chat_retries_transient_provider_error( +def test_chat_turn_limit_is_streamed_and_persisted( client: TestClient, projects_root, monkeypatch ) -> None: _login(client) project_id = _create_project(client, projects_root / "project") provider_id = _create_provider(client) - calls = {"n": 0} async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: return [ProviderModel("gpt-test", "GPT Test")] - async def stream(self, *_args, **_kwargs): - calls["n"] += 1 - if calls["n"] == 1: - raise ProviderAPIError("openai", 503, "temporarily overloaded") - yield TextDelta("Recovered") - yield TurnComplete("completed") + async def limit_stream(self, *_args, **_kwargs): + raise ChatTurnLimitError + yield monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) - monkeypatch.setattr(OpenAIAdapter, "stream", stream) - monkeypatch.setattr("app.modules.ai_chat.streaming.PROVIDER_RETRY_BACKOFF_SECONDS", 0.0) + monkeypatch.setattr(OpenAIAdapter, "stream", limit_stream) events = _events( client.post( f"/api/v1/projects/{project_id}/ai-chat/messages/stream", - json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Question"}, + json={ + "provider_id": provider_id, + "model_id": "gpt-test", + "message": "Question", + }, headers=_csrf(client), ) ) + conversation_id = events[0]["conversation"]["id"] + messages = client.get( + f"/api/v1/projects/{project_id}/ai-chat/conversations/{conversation_id}" + ).json()["messages"] - assert calls["n"] == 2 - assert events[-1]["type"] == "complete" - assert any(event["type"] == "text_delta" and event["text"] == "Recovered" for event in events) + assert events[-1]["type"] == "error" + assert events[-1]["code"] == "AI_CHAT_LIMIT_EXCEEDED" + assert messages[-1]["status"] == "failed" + assert messages[-1]["error"] == events[-1]["message"] -def test_readonly_context_tool_refuses_traversal_and_never_writes( +def test_chat_edit_and_resend_replaces_the_turn_and_later_messages( client: TestClient, projects_root, monkeypatch ) -> None: _login(client) - project_path = projects_root / "project" - project_id = _create_project(client, project_path) + project_id = _create_project(client, projects_root / "project") provider_id = _create_provider(client) - (project_path / "source.py").write_text("original", encoding="utf-8") - outside = projects_root / "outside.txt" - outside.write_text("outside-secret", encoding="utf-8") - tool_results: list[str] = [] - calls = {"stream": 0} + seen_history: list[list[str]] = [] async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: return [ProviderModel("gpt-test", "GPT Test")] @@ -495,127 +550,1003 @@ async def list_models(self, _client: AsyncClient, _api_key: str) -> list[Provide async def stream( self, _client, _api_key, _model, _system, messages, _tools, *, session_id=None ): - calls["stream"] += 1 - if calls["stream"] == 1: - yield ToolCall("read-1", "read_project_file", {"path": "../outside.txt"}) - else: - tool_results.extend(message.content for message in messages if message.role == "tool") - yield TextDelta("I could not read outside the project.") - yield TurnComplete("tool_calls" if calls["stream"] == 1 else "completed") + seen_history.append([message.content for message in messages]) + yield TextDelta(f"Answer to: {messages[-1].content}") + yield TurnComplete("completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + + def send(message: str, conversation_id: str | None = None, edit_id: int | None = None): + payload: dict[str, object] = { + "provider_id": provider_id, + "model_id": "gpt-test", + "message": message, + } + if conversation_id is not None: + payload["conversation_id"] = conversation_id + if edit_id is not None: + payload["edit_user_message_id"] = edit_id + return _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json=payload, + headers=_csrf(client), + ) + ) + + first = send("First question") + conversation_id = first[0]["conversation"]["id"] + send("Second question", conversation_id) + + messages = client.get( + f"/api/v1/projects/{project_id}/ai-chat/conversations/{conversation_id}" + ).json()["messages"] + assert [message["content"] for message in messages] == [ + "First question", + "Answer to: First question", + "Second question", + "Answer to: Second question", + ] + first_user_id = messages[0]["id"] + + seen_history.clear() + edited = send("Edited question", conversation_id, first_user_id) + + detail = client.get( + f"/api/v1/projects/{project_id}/ai-chat/conversations/{conversation_id}" + ).json() + assert edited[-1]["type"] == "complete" + # The edited turn and everything after it are replaced by the new turn. + assert [message["content"] for message in detail["messages"]] == [ + "Edited question", + "Answer to: Edited question", + ] + assert all(message["status"] == "completed" for message in detail["messages"]) + assert seen_history == [["Edited question"]] + + +def test_chat_edit_rejects_missing_or_assistant_message( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream(self, *_args, **_kwargs): + yield TextDelta("Answer") + yield TurnComplete("completed") monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) monkeypatch.setattr(OpenAIAdapter, "stream", stream) events = _events( client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Question"}, + headers=_csrf(client), + ) + ) + conversation_id = events[0]["conversation"]["id"] + messages = client.get( + f"/api/v1/projects/{project_id}/ai-chat/conversations/{conversation_id}" + ).json()["messages"] + assistant_id = messages[1]["id"] + + for edit_id in (assistant_id, 999_999): + response = client.post( f"/api/v1/projects/{project_id}/ai-chat/messages/stream", json={ + "conversation_id": conversation_id, "provider_id": provider_id, "model_id": "gpt-test", - "message": "Read outside the project", + "message": "Edited", + "edit_user_message_id": edit_id, }, headers=_csrf(client), ) - ) - - assert events[-1]["type"] == "complete" - assert tool_results == ['{"error":"FILE_PATH_INVALID"}'] - assert (project_path / "source.py").read_text(encoding="utf-8") == "original" + assert response.status_code == 404 + assert response.json()["detail"]["code"] == "AI_CHAT_MESSAGE_NOT_FOUND" + missing_conversation = client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={ + "provider_id": provider_id, + "model_id": "gpt-test", + "message": "Edited", + "edit_user_message_id": messages[0]["id"], + }, + headers=_csrf(client), + ) + assert missing_conversation.status_code == 422 -@pytest.mark.asyncio -async def test_cancelled_stream_persists_interrupted_status(monkeypatch) -> None: - from app.modules.ai_chat import service as chat_service + unchanged = client.get( + f"/api/v1/projects/{project_id}/ai-chat/conversations/{conversation_id}" + ).json()["messages"] + assert [message["content"] for message in unchanged] == ["Question", "Answer"] - statuses: list[MessageStatus] = [] - def finish(_db, _message, _content, status, thinking=""): - statuses.append(status) +def test_chat_injects_user_and_project_instructions_into_the_system_prompt( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + home = projects_root / "home" + (home / ".config" / "agents").mkdir(parents=True) + (home / ".config" / "agents" / "AGENTS.md").write_text( + "Always answer in Vietnamese.\n", encoding="utf-8" + ) + monkeypatch.setenv("HOME", str(home)) + project_path = projects_root / "project" + project_id = _create_project(client, project_path) + provider_id = _create_provider(client) + (project_path / "AGENTS.md").write_text("Use tabs, not spaces.\n", encoding="utf-8") + captured: list[str] = [] - class InterruptedAdapter: - kind = "openai" + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] - async def stream(self, *_args, **_kwargs): - yield TextDelta("Partial response") - raise asyncio.CancelledError + async def stream( + self, _client, _api_key, _model, system, _messages, _tools, *, session_id=None + ): + captured.append(system) + yield TextDelta("ok") + yield TurnComplete("completed") - monkeypatch.setattr(chat_service, "finish_assistant", finish) - assistant = StoredMessage(id=42) - user = User(id=1, username="alice") - stream = stream_chat_turn( - db=Mock(), - adapter=InterruptedAdapter(), - api_key="private", - model_id="test", - context=ProjectContext(Mock(), user, 1, "conversation"), - conversation_id="conversation", - conversation_title="test", - user_message_id=1, - user_content="Question", - assistant_message=assistant, - history=[], + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Question"}, + headers=_csrf(client), + ) ) - assert json.loads((await anext(stream)).decode())["type"] == "message_start" - assert json.loads((await anext(stream)).decode()) == { - "type": "text_delta", - "text": "Partial response", - } - with pytest.raises(asyncio.CancelledError): - await anext(stream) + assert len(captured) == 1 + assert "You are a code assistant" in captured[0] + assert "Always answer in Vietnamese." in captured[0] + assert "Use tabs, not spaces." in captured[0] - assert statuses == [MessageStatus.INTERRUPTED] +def test_chat_final_round_keeps_agent_instructions( + client: TestClient, projects_root, monkeypatch +) -> None: + """Tools are disabled for the last round, but the conventions must still apply.""" + _login(client) + monkeypatch.setenv("HOME", str(projects_root / "home")) + project_path = projects_root / "project" + project_id = _create_project(client, project_path) + provider_id = _create_provider(client) + (project_path / "AGENTS.md").write_text("Use tabs, not spaces.\n", encoding="utf-8") + systems: list[str] = [] -@pytest.mark.asyncio -async def test_cancelled_immediately_after_start_persists_interrupted_status(monkeypatch) -> None: - from app.modules.ai_chat import service as chat_service + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] - statuses: list[MessageStatus] = [] + async def stream( + self, _client, _api_key, _model, system, _messages, tools, *, session_id=None + ): + systems.append(system) + if tools: + yield ToolCall(f"call-{len(systems)}", "list_project_files", {}) + else: + yield TextDelta("Final answer") + yield TurnComplete("completed") - def finish(_db, _message, _content, status, thinking=""): - statuses.append(status) + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Question"}, + headers=_csrf(client), + ) + ) - class UnusedAdapter: - kind = "openai" + assert len(systems) == MAX_TOOL_ROUNDS_PER_TURN + assert "Tool use is now disabled for this turn." in systems[-1] + assert all("Use tabs, not spaces." in system for system in systems) - async def stream(self, *_args, **_kwargs): - yield TurnComplete("completed") - monkeypatch.setattr(chat_service, "finish_assistant", finish) - stream = stream_chat_turn( - db=Mock(), - adapter=UnusedAdapter(), - api_key="private", - model_id="test", - context=ProjectContext(Mock(), User(id=1, username="alice"), 1, "conversation"), - conversation_id="conversation", - conversation_title="test", - user_message_id=1, - user_content="Question", - assistant_message=StoredMessage(id=42), - history=[], +def test_skills_endpoint_lists_available_skills( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + home = projects_root / "home" + _write_skill( + home, "doc-writer", name="Doc Writer", description="Writes docs.", body="# Doc" + ) + _write_skill( + home, "interview-me", name="interview-me", description="Ask questions.", body="# Ask" ) + # Directories without a SKILL.md are ignored. + (home / ".agents" / "skills" / "not-a-skill").mkdir(parents=True) + monkeypatch.setenv("HOME", str(home)) + project_id = _create_project(client, projects_root / "project") - assert json.loads((await anext(stream)).decode())["type"] == "message_start" - with pytest.raises(asyncio.CancelledError): - await stream.athrow(asyncio.CancelledError) + response = client.get(f"/api/v1/projects/{project_id}/ai-chat/skills") - assert statuses == [MessageStatus.INTERRUPTED] + assert response.status_code == 200 + payload = response.json()["skills"] + assert [item["slug"] for item in payload] == ["doc-writer", "interview-me"] + assert payload[0] == { + "slug": "doc-writer", + "name": "Doc Writer", + "description": "Writes docs.", + } -@pytest.mark.asyncio -async def test_unexpected_stream_failure_persists_failed_status(monkeypatch) -> None: - from app.modules.ai_chat import service as chat_service +def test_skills_endpoint_requires_authentication(client: TestClient, projects_root) -> None: + _login(client) + project_id = _create_project(client, projects_root / "project") + client.cookies.clear() - statuses: list[MessageStatus] = [] + response = client.get(f"/api/v1/projects/{project_id}/ai-chat/skills") - def finish(_db, _message, _content, status, thinking=""): - statuses.append(status) + assert response.status_code == 401 - class FailingAdapter: - kind = "openai" - async def stream(self, *_args, **_kwargs): +def test_parse_frontmatter_folds_wrapped_description() -> None: + text = ( + "---\nname: doc-writer\ndescription: First line\n" + " wrapped line.\n---\n\n# Body\n\nContent.\n" + ) + meta, body = skills.parse_frontmatter(text) + + assert meta["name"] == "doc-writer" + assert meta["description"] == "First line wrapped line." + assert body.startswith("# Body") + + +def test_load_skill_body_strips_frontmatter(projects_root, monkeypatch) -> None: + home = projects_root / "home" + _write_skill( + home, "doc-writer", name="Doc Writer", description="Writes docs.", body="# Steps" + ) + monkeypatch.setenv("HOME", str(home)) + + assert skills.load_skill_body("doc-writer") == "# Steps" + assert skills.load_skill_body("../escape") is None + assert skills.load_skill_body("missing") is None + + +def test_chat_applies_selected_skill_to_the_system_prompt( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + home = projects_root / "home" + _write_skill( + home, + "doc-writer", + name="Doc Writer", + description="Writes docs.", + body="Always write documentation with examples.", + ) + monkeypatch.setenv("HOME", str(home)) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + captured: list[str] = [] + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, system, _messages, _tools, *, session_id=None + ): + captured.append(system) + yield TextDelta("ok") + yield TurnComplete("completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + + events = _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={ + "provider_id": provider_id, + "model_id": "gpt-test", + "message": "", + "skill": "doc-writer", + }, + headers=_csrf(client), + ) + ) + conversation_id = events[0]["conversation"]["id"] + messages = client.get( + f"/api/v1/projects/{project_id}/ai-chat/conversations/{conversation_id}" + ).json()["messages"] + + assert "Always write documentation with examples." in captured[0] + assert "Active skill" in captured[0] + assert messages[0]["role"] == "user" + assert messages[0]["content"] == "/doc-writer" + + +def test_chat_rejects_unknown_or_unsafe_skill( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + monkeypatch.setenv("HOME", str(projects_root / "home")) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + + for slug in ("missing-skill", "../escape"): + response = client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={ + "provider_id": provider_id, + "model_id": "gpt-test", + "message": "Hi", + "skill": slug, + }, + headers=_csrf(client), + ) + assert response.status_code == 422 + assert response.json()["detail"]["code"] == ErrorCode.AI_CHAT_SKILL_UNAVAILABLE.value + assert ( + client.get(f"/api/v1/projects/{project_id}/ai-chat/conversations").json()[ + "conversations" + ] + == [] + ) + + +def test_skills_endpoint_merges_project_and_user_skills( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + home = projects_root / "home" + _write_skill(home, "user-only", name="User Only", description="From user.", body="# U") + _write_skill(home, "shared", name="User Shared", description="User version.", body="# US") + monkeypatch.setenv("HOME", str(home)) + project_path = projects_root / "project" + project_id = _create_project(client, project_path) + _write_skill( + project_path, "project-only", name="Project Only", description="From project.", body="# P" + ) + _write_skill( + project_path, "shared", name="Project Shared", description="Project version.", body="# PS" + ) + + response = client.get(f"/api/v1/projects/{project_id}/ai-chat/skills") + + assert response.status_code == 200 + payload = response.json()["skills"] + assert [item["slug"] for item in payload] == ["project-only", "shared", "user-only"] + # The project skill shadows the user skill with the same slug. + shared = next(item for item in payload if item["slug"] == "shared") + assert shared["name"] == "Project Shared" + + +def test_chat_applies_a_project_skill_to_the_system_prompt( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + monkeypatch.setenv("HOME", str(projects_root / "home")) + project_path = projects_root / "project" + project_id = _create_project(client, project_path) + provider_id = _create_provider(client) + _write_skill( + project_path, + "project-doc", + name="Project Doc", + description="Docs.", + body="Follow the project documentation rules.", + ) + captured: list[str] = [] + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, system, _messages, _tools, *, session_id=None + ): + captured.append(system) + yield TextDelta("ok") + yield TurnComplete("completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={ + "provider_id": provider_id, + "model_id": "gpt-test", + "message": "", + "skill": "project-doc", + }, + headers=_csrf(client), + ) + ) + + assert "Follow the project documentation rules." in captured[0] + + +def test_read_file_tool_reads_a_project_skill_supporting_file( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + monkeypatch.setenv("HOME", str(projects_root / "home")) + project_path = projects_root / "project" + project_id = _create_project(client, project_path) + provider_id = _create_provider(client) + _write_skill( + project_path, "code-review", name="Code Review", description="Review.", body="# Review" + ) + (project_path / ".agents" / "skills" / "code-review" / "reference.md").write_text( + "project-skill-ref", encoding="utf-8" + ) + tool_results: list[str] = [] + calls = {"stream": 0} + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, _system, messages, _tools, *, session_id=None + ): + calls["stream"] += 1 + if calls["stream"] == 1: + yield ToolCall("read-1", "read_file", {"path": "code-review/reference.md"}) + else: + tool_results.extend(message.content for message in messages if message.role == "tool") + yield TextDelta("done") + yield TurnComplete("tool_calls" if calls["stream"] == 1 else "completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Read"}, + headers=_csrf(client), + ) + ) + + result = json.loads(tool_results[0]) + assert result["path"] == "code-review/reference.md" + assert result["content"] == "project-skill-ref" + assert result["source"] == "skill" + + +def test_read_file_tool_prefers_a_project_skill_file_over_a_user_skill_file( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + home = projects_root / "home" + _write_skill(home, "shared", name="Shared", description="Shared.", body="# Shared") + (home / ".agents" / "skills" / "shared" / "reference.md").write_text( + "user-version", encoding="utf-8" + ) + monkeypatch.setenv("HOME", str(home)) + project_path = projects_root / "project" + project_id = _create_project(client, project_path) + provider_id = _create_provider(client) + _write_skill(project_path, "shared", name="Shared", description="Shared.", body="# Shared") + (project_path / ".agents" / "skills" / "shared" / "reference.md").write_text( + "project-version", encoding="utf-8" + ) + tool_results: list[str] = [] + calls = {"stream": 0} + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, _system, messages, _tools, *, session_id=None + ): + calls["stream"] += 1 + if calls["stream"] == 1: + yield ToolCall("read-1", "read_file", {"path": "shared/reference.md"}) + else: + tool_results.extend(message.content for message in messages if message.role == "tool") + yield TextDelta("done") + yield TurnComplete("tool_calls" if calls["stream"] == 1 else "completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Read"}, + headers=_csrf(client), + ) + ) + + assert json.loads(tool_results[0])["content"] == "project-version" + + +def test_execute_project_tool_refuses_absolute_read_paths() -> None: + """Absolute reads must go through the streaming approval layer, never here.""" + context = Mock() + result = execute_project_tool( + context, ToolCall("read-1", "read_file", {"path": "/etc/passwd"}) + ) + assert json.loads(result) == {"error": "invalid_arguments"} + + +def test_read_file_tool_reads_an_absolute_project_path_without_approval( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + project_path = projects_root / "project" + project_id = _create_project(client, project_path) + provider_id = _create_provider(client) + target = project_path / "note.txt" + target.write_text("absolute-inside", encoding="utf-8") + + events, results = _stream_read(client, project_id, provider_id, str(target), monkeypatch) + + assert all(event["type"] != "read_approval" for event in events) + result = json.loads(results[0]) + assert result["content"] == "absolute-inside" + assert result["source"] == "project" + + +def test_read_file_tool_reads_an_outside_path_when_permission_is_allow_all( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + _set_permission(client, project_id, "allow_all") + outside = projects_root / "outside.txt" + outside.write_text("outside-content", encoding="utf-8") + + events, results = _stream_read(client, project_id, provider_id, str(outside), monkeypatch) + + assert all(event["type"] != "read_approval" for event in events) + result = json.loads(results[0]) + assert result["content"] == "outside-content" + assert result["source"] == "external" + + +def test_read_file_tool_reads_an_outside_path_after_approval( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + outside = projects_root / "approved.txt" + outside.write_text("approved-content", encoding="utf-8") + + async def approve(_pending) -> bool: + return True + + monkeypatch.setattr("app.modules.ai_chat.approvals.wait_for_decision", approve) + + events, results = _stream_read(client, project_id, provider_id, str(outside), monkeypatch) + + assert any(event["type"] == "read_approval" for event in events) + assert json.loads(results[0])["content"] == "approved-content" + + +def test_read_file_tool_denies_an_outside_path_when_the_user_declines( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + outside = projects_root / "declined.txt" + outside.write_text("declined-content", encoding="utf-8") + + async def decline(_pending) -> bool: + return False + + monkeypatch.setattr("app.modules.ai_chat.approvals.wait_for_decision", decline) + + events, results = _stream_read(client, project_id, provider_id, str(outside), monkeypatch) + + assert any(event["type"] == "read_approval" for event in events) + assert all(event["type"] != "read_approval_expired" for event in events) + assert json.loads(results[0])["error"] == "read_cancelled" + assert "declined-content" not in "".join(results) + + +def test_read_file_tool_requires_approval_for_an_outside_path( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + outside = projects_root / "outside-secret.txt" + outside.write_text("secret", encoding="utf-8") + monkeypatch.setattr("app.modules.ai_chat.approvals.APPROVAL_TIMEOUT_SECONDS", 0.05) + + events, results = _stream_read(client, project_id, provider_id, str(outside), monkeypatch) + + types = [event["type"] for event in events] + assert "read_approval" in types + assert "read_approval_expired" in types + approval = next(event for event in events if event["type"] == "read_approval") + assert approval["path"] == str(outside) + assert json.loads(results[0])["error"] == "approval_timed_out" + assert "secret" not in "".join(results) + + +def test_chat_retries_transient_provider_error( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + calls = {"n": 0} + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream(self, *_args, **_kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise ProviderAPIError("openai", 503, "temporarily overloaded") + yield TextDelta("Recovered") + yield TurnComplete("completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + monkeypatch.setattr("app.modules.ai_chat.streaming.PROVIDER_RETRY_BACKOFF_SECONDS", 0.0) + events = _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Question"}, + headers=_csrf(client), + ) + ) + + assert calls["n"] == 2 + assert events[-1]["type"] == "complete" + assert any(event["type"] == "text_delta" and event["text"] == "Recovered" for event in events) + + +def test_readonly_context_tool_refuses_traversal_and_never_writes( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + project_path = projects_root / "project" + project_id = _create_project(client, project_path) + provider_id = _create_provider(client) + (project_path / "source.py").write_text("original", encoding="utf-8") + outside = projects_root / "outside.txt" + outside.write_text("outside-secret", encoding="utf-8") + tool_results: list[str] = [] + calls = {"stream": 0} + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, _system, messages, _tools, *, session_id=None + ): + calls["stream"] += 1 + if calls["stream"] == 1: + yield ToolCall("read-1", "read_file", {"path": "../outside.txt"}) + else: + tool_results.extend(message.content for message in messages if message.role == "tool") + yield TextDelta("I could not read outside the project.") + yield TurnComplete("tool_calls" if calls["stream"] == 1 else "completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + events = _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={ + "provider_id": provider_id, + "model_id": "gpt-test", + "message": "Read outside the project", + }, + headers=_csrf(client), + ) + ) + + assert events[-1]["type"] == "complete" + assert tool_results == ['{"error":"FILE_PATH_INVALID"}'] + assert (project_path / "source.py").read_text(encoding="utf-8") == "original" + + +def test_read_file_tool_reads_a_supporting_skill_file( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + home = projects_root / "home" + _write_skill( + home, + "code-review", + name="Code Review", + description="Review code.", + body="Read reference.md before reviewing.", + ) + reference = home / ".agents" / "skills" / "code-review" / "reference.md" + reference.write_text("# Checklist\n\n- Check error handling\n", encoding="utf-8") + monkeypatch.setenv("HOME", str(home)) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + tool_results: list[str] = [] + calls = {"stream": 0} + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, _system, messages, _tools, *, session_id=None + ): + calls["stream"] += 1 + if calls["stream"] == 1: + yield ToolCall("read-1", "read_file", {"path": "code-review/reference.md"}) + else: + tool_results.extend(message.content for message in messages if message.role == "tool") + yield TextDelta("Reviewed using the checklist.") + yield TurnComplete("tool_calls" if calls["stream"] == 1 else "completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + events = _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Review"}, + headers=_csrf(client), + ) + ) + + assert events[-1]["type"] == "complete" + result = json.loads(tool_results[0]) + assert result["path"] == "code-review/reference.md" + assert "Check error handling" in result["content"] + assert result["truncated"] is False + assert result["source"] == "skill" + + +def test_read_file_tool_prefers_a_project_file_over_a_skill_file( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + home = projects_root / "home" + _write_skill( + home, "shared", name="Shared", description="Shared.", body="# Shared" + ) + (home / ".agents" / "skills" / "shared" / "reference.md").write_text( + "skill-version", encoding="utf-8" + ) + monkeypatch.setenv("HOME", str(home)) + project_path = projects_root / "project" + project_id = _create_project(client, project_path) + provider_id = _create_provider(client) + (project_path / "shared").mkdir() + (project_path / "shared" / "reference.md").write_text("project-version", encoding="utf-8") + tool_results: list[str] = [] + calls = {"stream": 0} + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, _system, messages, _tools, *, session_id=None + ): + calls["stream"] += 1 + if calls["stream"] == 1: + yield ToolCall("read-1", "read_file", {"path": "shared/reference.md"}) + else: + tool_results.extend(message.content for message in messages if message.role == "tool") + yield TextDelta("done") + yield TurnComplete("tool_calls" if calls["stream"] == 1 else "completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Read"}, + headers=_csrf(client), + ) + ) + + result = json.loads(tool_results[0]) + assert result["content"] == "project-version" + assert result["source"] == "project" + + +def test_read_file_tool_handles_a_symlinked_skills_root( + client: TestClient, projects_root, monkeypatch +) -> None: + """A symlinked home must not break skill reads (root vs. resolved paths).""" + _login(client) + real_home = projects_root / "real-home" + _write_skill( + real_home, "code-review", name="Code Review", description="Review.", body="# Review" + ) + (real_home / ".agents" / "skills" / "code-review" / "reference.md").write_text( + "symlink-ok", encoding="utf-8" + ) + home_link = projects_root / "home" + home_link.symlink_to(real_home) + monkeypatch.setenv("HOME", str(home_link)) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + tool_results: list[str] = [] + calls = {"stream": 0} + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, _system, messages, _tools, *, session_id=None + ): + calls["stream"] += 1 + if calls["stream"] == 1: + yield ToolCall("read-1", "read_file", {"path": "code-review/reference.md"}) + else: + tool_results.extend(message.content for message in messages if message.role == "tool") + yield TextDelta("done") + yield TurnComplete("tool_calls" if calls["stream"] == 1 else "completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Read"}, + headers=_csrf(client), + ) + ) + + result = json.loads(tool_results[0]) + assert result["path"] == "code-review/reference.md" + assert result["content"] == "symlink-ok" + + +def test_read_file_tool_rejects_escapes_and_missing_files( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + home = projects_root / "home" + _write_skill( + home, "code-review", name="Code Review", description="Review code.", body="# Review" + ) + (home / "secret.txt").write_text("home-secret", encoding="utf-8") + (home / ".agents" / "skills" / "escape-link").symlink_to(home) + monkeypatch.setenv("HOME", str(home)) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + tool_results: list[str] = [] + calls = {"stream": 0} + + async def list_models(self, _client: AsyncClient, _api_key: str) -> list[ProviderModel]: + return [ProviderModel("gpt-test", "GPT Test")] + + async def stream( + self, _client, _api_key, _model, _system, messages, _tools, *, session_id=None + ): + calls["stream"] += 1 + if calls["stream"] == 1: + yield ToolCall("t-1", "read_file", {"path": "../secret.txt"}) + yield ToolCall("t-2", "read_file", {"path": "escape-link/secret.txt"}) + yield ToolCall("t-3", "read_file", {"path": "code-review/missing.md"}) + else: + tool_results.extend(message.content for message in messages if message.role == "tool") + yield TextDelta("done") + yield TurnComplete("tool_calls" if calls["stream"] == 1 else "completed") + + monkeypatch.setattr(OpenAIAdapter, "list_models", list_models) + monkeypatch.setattr(OpenAIAdapter, "stream", stream) + events = _events( + client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": "Read"}, + headers=_csrf(client), + ) + ) + + assert events[-1]["type"] == "complete" + assert tool_results == [ + '{"error":"FILE_PATH_INVALID"}', + '{"error":"FILE_PATH_INVALID"}', + '{"error":"FILE_NOT_FOUND"}', + ] + + +@pytest.mark.asyncio +async def test_cancelled_stream_persists_interrupted_status(monkeypatch) -> None: + from app.modules.ai_chat import service as chat_service + + statuses: list[MessageStatus] = [] + + def finish(_db, _message, _content, status, thinking=""): + statuses.append(status) + + class InterruptedAdapter: + kind = "openai" + + async def stream(self, *_args, **_kwargs): + yield TextDelta("Partial response") + raise asyncio.CancelledError + + monkeypatch.setattr(chat_service, "finish_assistant", finish) + assistant = StoredMessage(id=42) + user = User(id=1, username="alice") + stream = stream_chat_turn( + db=Mock(), + adapter=InterruptedAdapter(), + api_key="private", + model_id="test", + context=ProjectContext(Mock(), user, 1, "conversation"), + conversation_id="conversation", + conversation_title="test", + user_message_id=1, + user_content="Question", + assistant_message=assistant, + history=[], + ) + + assert json.loads((await anext(stream)).decode())["type"] == "message_start" + assert json.loads((await anext(stream)).decode()) == { + "type": "text_delta", + "text": "Partial response", + } + with pytest.raises(asyncio.CancelledError): + await anext(stream) + + assert statuses == [MessageStatus.INTERRUPTED] + + +@pytest.mark.asyncio +async def test_cancelled_immediately_after_start_persists_interrupted_status(monkeypatch) -> None: + from app.modules.ai_chat import service as chat_service + + statuses: list[MessageStatus] = [] + + def finish(_db, _message, _content, status, thinking=""): + statuses.append(status) + + class UnusedAdapter: + kind = "openai" + + async def stream(self, *_args, **_kwargs): + yield TurnComplete("completed") + + monkeypatch.setattr(chat_service, "finish_assistant", finish) + stream = stream_chat_turn( + db=Mock(), + adapter=UnusedAdapter(), + api_key="private", + model_id="test", + context=ProjectContext(Mock(), User(id=1, username="alice"), 1, "conversation"), + conversation_id="conversation", + conversation_title="test", + user_message_id=1, + user_content="Question", + assistant_message=StoredMessage(id=42), + history=[], + ) + + assert json.loads((await anext(stream)).decode())["type"] == "message_start" + with pytest.raises(asyncio.CancelledError): + await stream.athrow(asyncio.CancelledError) + + assert statuses == [MessageStatus.INTERRUPTED] + + +@pytest.mark.asyncio +async def test_unexpected_stream_failure_persists_failed_status(monkeypatch) -> None: + from app.modules.ai_chat import service as chat_service + + statuses: list[MessageStatus] = [] + + def finish(_db, _message, _content, status, thinking=""): + statuses.append(status) + + class FailingAdapter: + kind = "openai" + + async def stream(self, *_args, **_kwargs): raise RuntimeError("unexpected provider failure") yield @@ -639,3 +1570,20 @@ async def stream(self, *_args, **_kwargs): await anext(stream) assert statuses == [MessageStatus.FAILED] + + +def test_chat_requires_message_or_skill( + client: TestClient, projects_root, monkeypatch +) -> None: + _login(client) + monkeypatch.setenv("HOME", str(projects_root / "home")) + project_id = _create_project(client, projects_root / "project") + provider_id = _create_provider(client) + + response = client.post( + f"/api/v1/projects/{project_id}/ai-chat/messages/stream", + json={"provider_id": provider_id, "model_id": "gpt-test", "message": ""}, + headers=_csrf(client), + ) + + assert response.status_code == 422 diff --git a/tests/test_ai_chat_commands.py b/tests/test_ai_chat_commands.py index 794ab80..bb0d9de 100644 --- a/tests/test_ai_chat_commands.py +++ b/tests/test_ai_chat_commands.py @@ -24,7 +24,7 @@ def _create_project(client: TestClient, path: Path) -> int: async def _create_approval( user_id: int, project_id: int, command: str ) -> approvals.PendingApproval: - return approvals.create_approval(user_id, project_id, command) + return approvals.create_approval(user_id, project_id, "command", command) def test_command_permission_is_persisted_per_user(client: TestClient, projects_root: Path) -> None: @@ -213,7 +213,7 @@ def test_command_runner_caps_output(monkeypatch, tmp_path: Path) -> None: def test_approval_is_bound_to_user_and_project() -> None: async def exercise() -> None: - pending = approvals.create_approval(10, 20, "pwd") + pending = approvals.create_approval(10, 20, "command", "pwd") waiting = asyncio.create_task(approvals.wait_for_decision(pending)) await asyncio.sleep(0) @@ -232,10 +232,10 @@ def test_approval_route_authorizes_project_and_resolves_pending_request( _login(client) project_id = _create_project(client, projects_root / "approval-project") pending = asyncio.run(_create_approval(1, project_id, "pwd")) - url = f"/api/v1/projects/{project_id}/ai-chat/commands/{pending.approval_id}" + url = f"/api/v1/projects/{project_id}/ai-chat/approvals/{pending.approval_id}" wrong_project = client.post( - f"/api/v1/projects/{project_id + 999}/ai-chat/commands/{pending.approval_id}", + f"/api/v1/projects/{project_id + 999}/ai-chat/approvals/{pending.approval_id}", json={"approved": True}, headers=_csrf(client), ) diff --git a/tests/test_ai_chat_instructions.py b/tests/test_ai_chat_instructions.py new file mode 100644 index 0000000..c560f95 --- /dev/null +++ b/tests/test_ai_chat_instructions.py @@ -0,0 +1,103 @@ +from pathlib import Path + +from app.modules.ai_chat.instructions import ( + MAX_INSTRUCTIONS_CHARS, + MAX_INSTRUCTIONS_FILE_CHARS, + PROJECT_INSTRUCTIONS_NAME, + load_agent_instructions, + user_instructions_path, +) + + +def _home_with_user_instructions(tmp_path: Path, content: str) -> Path: + home = tmp_path / "home" + (home / ".config" / "agents").mkdir(parents=True) + (home / ".config" / "agents" / PROJECT_INSTRUCTIONS_NAME).write_text( + content, encoding="utf-8" + ) + return home + + +def test_returns_empty_when_no_instruction_files_exist(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path / "home")) + project = tmp_path / "project" + project.mkdir() + + assert load_agent_instructions(project) == "" + + +def test_loads_project_instructions(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path / "home")) + project = tmp_path / "project" + project.mkdir() + (project / PROJECT_INSTRUCTIONS_NAME).write_text("Use tabs.\n", encoding="utf-8") + + result = load_agent_instructions(project) + + assert "Use tabs." in result + assert "Project instructions" in result + assert "User instructions" not in result + + +def test_loads_user_before_project_instructions(tmp_path, monkeypatch) -> None: + home = _home_with_user_instructions(tmp_path, "Global rule.\n") + monkeypatch.setenv("HOME", str(home)) + project = tmp_path / "project" + project.mkdir() + (project / PROJECT_INSTRUCTIONS_NAME).write_text("Project rule.\n", encoding="utf-8") + + result = load_agent_instructions(project) + + assert "User instructions" in result + assert "Project instructions" in result + assert result.index("Global rule.") < result.index("Project rule.") + assert user_instructions_path() == home / ".config" / "agents" / PROJECT_INSTRUCTIONS_NAME + + +def test_caps_the_combined_instructions(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HOME", str(_home_with_user_instructions(tmp_path, "x" * 20_000))) + project = tmp_path / "project" + project.mkdir() + (project / PROJECT_INSTRUCTIONS_NAME).write_text("y" * 20_000, encoding="utf-8") + + result = load_agent_instructions(project) + + # Per-file read limit applies first, then the combined cap. + assert result.count("x") == MAX_INSTRUCTIONS_FILE_CHARS + assert len(result) == MAX_INSTRUCTIONS_CHARS + assert result.startswith("# User instructions") + + +def test_skips_unreadable_instruction_paths(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("HOME", str(tmp_path / "home")) + project = tmp_path / "project" + project.mkdir() + # A directory named like the file must be skipped, not raised. + (project / PROJECT_INSTRUCTIONS_NAME).mkdir() + + assert load_agent_instructions(project) == "" + + +def test_accepts_a_missing_project_path(tmp_path, monkeypatch) -> None: + home = _home_with_user_instructions(tmp_path, "Global rule.\n") + monkeypatch.setenv("HOME", str(home)) + + result = load_agent_instructions(None) + + assert "Global rule." in result + assert "Project instructions" not in result + + +def test_does_not_send_absolute_server_paths(tmp_path, monkeypatch) -> None: + home = _home_with_user_instructions(tmp_path, "Global rule.\n") + monkeypatch.setenv("HOME", str(home)) + project = tmp_path / "project" + project.mkdir() + (project / PROJECT_INSTRUCTIONS_NAME).write_text("Project rule.\n", encoding="utf-8") + + result = load_agent_instructions(project) + + assert str(home) not in result + assert str(project) not in result + assert "~/.config/agents/AGENTS.md" in result + assert "(AGENTS.md)" in result diff --git a/web/package-lock.json b/web/package-lock.json index 3fbdec0..425c206 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,7 +17,9 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.16", - "react-router-dom": "^7.18.4" + "react-markdown": "^10.1.0", + "react-router-dom": "^7.18.4", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -3781,6 +3783,15 @@ "node": ">=12" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3792,9 +3803,26 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3802,6 +3830,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.19.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", @@ -3816,7 +3859,6 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3846,6 +3888,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.71.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", @@ -4089,6 +4137,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, "node_modules/@vite-pwa/assets-generator": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@vite-pwa/assets-generator/-/assets-generator-2.0.0.tgz", @@ -4322,6 +4376,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -4494,6 +4558,66 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -4587,7 +4711,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -4648,7 +4771,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4691,6 +4813,19 @@ "node": ">=8.6" } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4751,6 +4886,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4761,6 +4905,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dompurify": { "version": "3.4.15", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", @@ -5178,6 +5335,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -5208,6 +5375,12 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5727,6 +5900,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -5760,6 +5973,16 @@ "url": "https://locize.com" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/i18next": { "version": "26.4.2", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", @@ -5831,6 +6054,12 @@ "node": ">=0.8.19" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5846,6 +6075,30 @@ "node": ">= 0.4" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5981,6 +6234,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-document.all": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", @@ -6056,6 +6319,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -6116,6 +6389,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6730,6 +7015,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6750,6 +7045,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/marked": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", @@ -6772,87 +7077,954 @@ "node": ">= 0.4" } }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/monaco-editor": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz", - "integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==", + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", "license": "MIT", "dependencies": { - "dompurify": "3.4.15", - "marked": "14.0.0" + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.20", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", - "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.57", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", - "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", - "dev": true, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/object-inspect": { - "version": "1.13.4", + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/monaco-editor": { + "version": "0.57.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.57.0.tgz", + "integrity": "sha512-5BkI9KGoqrNvBGUe15/QlZq3OooZ8WLg1AxTpaqHRCP3HNpzPPZKE2EDz8M7c+VRmCeUw1Brp4cx/PWm3kI/5A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.4.15", + "marked": "14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, @@ -6971,6 +8143,31 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7107,6 +8304,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7202,6 +8409,33 @@ } } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-router": { "version": "7.18.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", @@ -7342,6 +8576,72 @@ "regjsparser": "bin/parser" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7828,6 +9128,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -7936,6 +9246,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", @@ -7961,6 +9285,24 @@ "node": ">=10" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -8067,6 +9409,26 @@ "dev": true, "license": "MIT" }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8330,6 +9692,25 @@ "node": ">=4" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -8343,6 +9724,74 @@ "node": ">=8" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -8414,6 +9863,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", @@ -8941,6 +10418,16 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/web/package.json b/web/package.json index 9481b91..a81054c 100644 --- a/web/package.json +++ b/web/package.json @@ -20,7 +20,9 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-i18next": "^17.0.16", - "react-router-dom": "^7.18.4" + "react-markdown": "^10.1.0", + "react-router-dom": "^7.18.4", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/web/src/components/ide/ChatPanel.tsx b/web/src/components/ide/ChatPanel.tsx index 75629cb..7d2e4c9 100644 --- a/web/src/components/ide/ChatPanel.tsx +++ b/web/src/components/ide/ChatPanel.tsx @@ -1,8 +1,16 @@ import { useEffect, useRef, useState, type FormEvent } from 'react' import { useTranslation } from 'react-i18next' -import { useIsMobile } from '../../lib/useMediaQuery' import { - decideAICommand, + ArrowUpIcon, + ChevronDownIcon, + MonitorIcon, + PinIcon, + PlusIcon, + WarningIcon, +} from './icons' +import { MarkdownMessage } from './MarkdownMessage' +import { + decideAIApproval, consumeChatStream, fetchAICommandPermission, fetchAIConversation, @@ -10,6 +18,7 @@ import { fetchAIProviderModels, fetchAIProposals, fetchAIProviders, + fetchAISkills, openAIChatStream, updateAIProposal, updateAICommandPermission, @@ -19,11 +28,45 @@ import { type AIConversation, type AIProvider, type AIProviderModel, + type AISkill, } from '../../lib/chat' -import { ChevronDownIcon, SettingsIcon } from './icons' -const selectClass = - 'rounded-md border border-[var(--border)] bg-[var(--bg)] px-2.5 py-1.5 text-base text-[var(--fg)] outline-none focus:border-indigo-500 md:text-xs' +const BRAND_GRADIENT = 'linear-gradient(90deg, #C75A45 0%, #D98A5B 100%)' +const brandStyle = { backgroundImage: BRAND_GRADIENT } + +const MAX_TEXTAREA_HEIGHT = 192 +const MIN_TEXTAREA_HEIGHT = 24 +const MIN_EDIT_HEIGHT = 72 +const MAX_MESSAGE_CHARS = 16000 +const SKILL_QUERY_PATTERN = /^\/([A-Za-z0-9._-]*)$/ + +const pageBg = 'bg-[#F9F9F9] dark:bg-[#0F0F0F]' +const cardBg = 'bg-white dark:bg-[#1F1F1F]' +const subtleBg = 'bg-[#F2F2F2] dark:bg-[#272727]' +const cardBorder = 'border-[#E5E7EB] dark:border-[#2E2E2E]' +const userBubbleBg = 'bg-[#E8F0FE] dark:bg-[#1E2A3A]' +const primaryText = 'text-[#0F0F0F] dark:text-[#F1F1F1]' +const mutedText = 'text-[#6B6B6B] dark:text-[#AAAAAA]' +const dangerText = 'text-[#DC2626] dark:text-[#F87171]' +const accentText = 'text-[#C25C49]' +const accentBg = 'bg-[#C25C49]' +const dotIdle = 'bg-[#B8B8B8] dark:bg-[#555555]' +const focusRing = + 'focus:border-[#C25C49] focus:ring-2 focus:ring-[#C25C49]/30' +const placeholderText = 'placeholder:text-[#6B6B6B] dark:placeholder:text-[#AAAAAA]' + +const compactSelectClass = `appearance-none rounded-full border border-[#E7E7E7] bg-[#F5F5F5] px-2.5 py-1 text-[11px] ${primaryText} outline-none transition ${focusRing} disabled:opacity-50` + +const composerShell = `flex flex-col rounded-3xl border ${cardBorder} ${cardBg} px-3 pb-2 pt-3 shadow-sm transition focus-within:border-[#C25C49] focus-within:ring-2 focus-within:ring-[#C25C49]/25` +const composerIconButton = `inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${mutedText} transition hover:bg-black/5 disabled:opacity-40 dark:hover:bg-white/10` +const composerSendButton = + 'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-white shadow-sm transition enabled:hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:brightness-100' +const composerSelect = `max-w-[10.5rem] cursor-pointer appearance-none truncate rounded-full bg-transparent py-1 pl-2.5 pr-6 text-[12px] ${primaryText} outline-none transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10` +const composerMetaItem = `inline-flex items-center gap-1.5 rounded-full bg-transparent px-1.5 py-1 text-[11px] ${mutedText} transition hover:bg-black/5 dark:hover:bg-white/10` + +const gradientButton = + 'inline-flex items-center justify-center rounded-full font-medium text-white shadow-sm transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:brightness-100' +const ghostButton = `inline-flex items-center justify-center rounded-full border ${cardBorder} ${subtleBg} px-3 py-1.5 text-xs ${primaryText} transition hover:brightness-95 disabled:opacity-50` type ToolLabelKey = | 'chat.toolList' @@ -39,7 +82,7 @@ type ToolLabelKey = const TOOL_LABEL_KEYS: Record = { list_project_files: 'chat.toolList', - read_project_file: 'chat.toolRead', + read_file: 'chat.toolRead', search_project_files: 'chat.toolSearch', propose_file_change: 'chat.toolPropose', create_project_file: 'chat.toolCreate', @@ -65,7 +108,7 @@ function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean } type="button" onClick={() => setOpen((value) => !value)} aria-expanded={open} - className="flex items-center gap-1 text-xs font-medium text-[var(--muted-2)] transition hover:text-[var(--fg-2)]" + className={`flex items-center gap-1 text-xs font-medium ${mutedText} transition hover:opacity-80`} > {open && ( -
+
{text || t('chat.thinking')}
)} @@ -88,17 +131,20 @@ function ThinkingBlock({ text, streaming }: { text: string; streaming: boolean } function ToolSteps({ steps, streaming }: { steps: string[]; streaming: boolean }) { return (
    - {steps.map((step, index) => ( -
  • - - {step} -
  • - ))} + {steps.map((step, index) => { + const running = streaming && index === steps.length - 1 + return ( +
  • + + {step} +
  • + ) + })}
) } @@ -119,30 +165,36 @@ export function ChatPanel({ onStreamingChange?: (streaming: boolean) => void }) { const { t } = useTranslation() - const isMobile = useIsMobile() const [providers, setProviders] = useState([]) const [providerId, setProviderId] = useState(null) const [models, setModels] = useState([]) const [modelId, setModelId] = useState('') const [conversations, setConversations] = useState([]) const [proposals, setProposals] = useState([]) + const [skills, setSkills] = useState([]) + const [selectedSkill, setSelectedSkill] = useState(null) + const [skillIndex, setSkillIndex] = useState(0) const [commandPermission, setCommandPermission] = useState('manual') const [permissionSaving, setPermissionSaving] = useState(false) - const [pendingCommand, setPendingCommand] = useState<{ + const [pendingApproval, setPendingApproval] = useState<{ approvalId: string - command: string + kind: 'command' | 'read' + detail: string } | null>(null) const [approvalBusy, setApprovalBusy] = useState(false) const [selectedConversation, setSelectedConversation] = useState(conversationId ?? '') const [messages, setMessages] = useState([]) const [draft, setDraft] = useState('') + const [editingId, setEditingId] = useState(null) + const [editDraft, setEditDraft] = useState('') const [loading, setLoading] = useState(true) const [streaming, setStreaming] = useState(false) const [error, setError] = useState('') const [loadError, setLoadError] = useState('') - const [controlsOpen, setControlsOpen] = useState(false) const abortRef = useRef(null) const endRef = useRef(null) + const textareaRef = useRef(null) + const editTextareaRef = useRef(null) const optimisticIdRef = useRef(0) const onStreamingChangeRef = useRef(onStreamingChange) @@ -185,6 +237,20 @@ export function ChatPanel({ } }, [projectId, t]) + useEffect(() => { + let alive = true + fetchAISkills(projectId) + .then((result) => { + if (alive) setSkills(result) + }) + .catch(() => { + if (alive) setSkills([]) + }) + return () => { + alive = false + } + }, [projectId]) + useEffect(() => { if (!providerId) { return @@ -233,7 +299,21 @@ export function ChatPanel({ useEffect(() => { endRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' }) - }, [messages, pendingCommand]) + }, [messages, pendingApproval]) + + useEffect(() => { + const el = textareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(Math.max(el.scrollHeight, MIN_TEXTAREA_HEIGHT), MAX_TEXTAREA_HEIGHT)}px` + }, [draft]) + + useEffect(() => { + const el = editTextareaRef.current + if (!el) return + el.style.height = 'auto' + el.style.height = `${Math.min(Math.max(el.scrollHeight, MIN_EDIT_HEIGHT), MAX_TEXTAREA_HEIGHT)}px` + }, [editDraft, editingId]) async function refreshConversations(): Promise { const result = await fetchAIConversations(projectId) @@ -253,13 +333,13 @@ export function ChatPanel({ } } - async function decideCommand(approved: boolean) { - if (!pendingCommand || approvalBusy) return + async function decideApproval(approved: boolean) { + if (!pendingApproval || approvalBusy) return setApprovalBusy(true) setError('') try { - await decideAICommand(projectId, pendingCommand.approvalId, approved) - setPendingCommand(null) + await decideAIApproval(projectId, pendingApproval.approvalId, approved) + setPendingApproval(null) } catch (reason) { setError(reason instanceof Error ? reason.message : t('chat.commandDecisionFailed')) abortRef.current?.abort() @@ -270,6 +350,7 @@ export function ChatPanel({ async function selectConversation(id: string) { setSelectedConversation(id) + setSelectedSkill(null) setError('') if (!id) { setMessages([]) @@ -292,11 +373,44 @@ export function ChatPanel({ } } - async function send(event: FormEvent) { + function beginEdit(message: AIChatMessage) { + if (streaming) return + setEditingId(message.id) + setEditDraft(message.content) + setError('') + } + + async function resendEdit(message: AIChatMessage) { + const content = editDraft.trim() + if (!content || streaming) return + setEditingId(null) + await startTurn(content, message.id) + } + + function selectSkill(skill: AISkill) { + setSelectedSkill(skill) + setDraft('') + setSkillIndex(0) + textareaRef.current?.focus() + } + + function send(event: FormEvent) { event.preventDefault() const content = draft.trim() - if (!content || !providerId || !modelId || streaming) return + if ((!content && !selectedSkill) || !providerId || !modelId || streaming) return + const skill = selectedSkill setDraft('') + setSelectedSkill(null) + void startTurn(content, undefined, skill) + } + + async function startTurn( + content: string, + editUserMessageId?: number, + skill?: AISkill | null, + ) { + if ((!content && !skill) || !providerId || !modelId || streaming) return + const userContent = content || (skill ? `/${skill.slug}` : '') setError('') setLoadError('') setStreaming(true) @@ -309,11 +423,13 @@ export function ChatPanel({ let activeConversationId = selectedConversation let streamCompleted = false setMessages((current) => [ - ...current, + ...(editUserMessageId === undefined + ? current + : current.filter((message) => message.id < editUserMessageId)), { id: optimisticUserId, role: 'user', - content, + content: userContent, thinking: '', status: 'completed', created_at: new Date().toISOString(), @@ -334,7 +450,9 @@ export function ChatPanel({ conversation_id: selectedConversation || null, provider_id: providerId, model_id: modelId, - message: content, + message: userContent, + skill: skill?.slug ?? null, + edit_user_message_id: editUserMessageId ?? null, }, controller.signal, ) @@ -373,13 +491,23 @@ export function ChatPanel({ ), ) } else if (streamEvent.type === 'command_approval') { - setPendingCommand({ + setPendingApproval({ approvalId: streamEvent.approval_id, - command: streamEvent.command, + kind: 'command', + detail: streamEvent.command, }) } else if (streamEvent.type === 'command_approval_expired') { - setPendingCommand(null) + setPendingApproval(null) setError(t('chat.commandApprovalExpired')) + } else if (streamEvent.type === 'read_approval') { + setPendingApproval({ + approvalId: streamEvent.approval_id, + kind: 'read', + detail: streamEvent.path, + }) + } else if (streamEvent.type === 'read_approval_expired') { + setPendingApproval(null) + setError(t('chat.readApprovalExpired')) } else if (streamEvent.type === 'proposal') { const proposal: AIChangeProposal = { id: streamEvent.id, @@ -459,17 +587,28 @@ export function ChatPanel({ } } finally { if (abortRef.current === controller) abortRef.current = null - setPendingCommand(null) + setPendingApproval(null) setStreaming(false) } } const provider = providers.find((item) => item.id === providerId) - const canSend = Boolean(provider && modelId && draft.trim() && !streaming) - const fieldLabelClass = `flex text-xs text-[var(--muted-2)] ${ - isMobile ? 'flex-col gap-1' : 'items-center gap-2' - }` - const fieldSelectClass = `${selectClass} ${isMobile ? 'w-full' : ''}` + const canSend = Boolean( + provider && modelId && (draft.trim() || selectedSkill) && !streaming, + ) + const skillQuery = SKILL_QUERY_PATTERN.exec(draft)?.[1]?.toLowerCase() ?? null + const filteredSkills = + skillQuery === null + ? [] + : skills.filter((skill) => skill.slug.toLowerCase().includes(skillQuery)) + const skillMenuOpen = skillQuery !== null && !streaming && filteredSkills.length > 0 + const activeSkillIndex = Math.min(skillIndex, Math.max(filteredSkills.length - 1, 0)) + const activeTitle = + conversations.find((item) => item.id === selectedConversation)?.title ?? '' + const pinnedMessage = + messages + .findLast((message) => message.role === 'user' && message.content.trim() !== '') + ?.content.trim() ?? '' function describeTool(name: string, path?: string): string { const labelKey = TOOL_LABEL_KEYS[name] @@ -506,187 +645,190 @@ export function ChatPanel({
-
- {isMobile && ( - - )} -
- - - - + + + {t('chat.pinnedMessage')} · {pinnedMessage} + +

+ )}
+
-
- {loading &&

{t('common.loading')}

} +
+ {loading &&

{t('common.loading')}

} {!loading && providers.length === 0 && (
-

{t('chat.noProviders')}

-

{t('chat.configureProvider')}

+

{t('chat.noProviders')}

+

{t('chat.configureProvider')}

)} {!loading && providers.length > 0 && messages.length === 0 && ( -
+
{t('chat.empty')}
)} - {loadError &&

{loadError}

} - {messages.map((message) => ( -
-

- {message.role === 'user' ? t('chat.you') : t('chat.assistant')} -

- {message.role === 'assistant' && message.thinking && ( - - )} - {message.role === 'assistant' && message.steps && message.steps.length > 0 && ( - - )} -
- {message.content || - (message.status === 'streaming' && !message.thinking && !message.steps?.length - ? t('chat.thinking') - : '')} -
- {message.status === 'failed' && ( -

- {message.error || t('chat.failedStatus')} -

- )} - {message.role === 'assistant' && message.notice && ( -

{message.notice}

- )} - {message.status === 'interrupted' && ( -

{t('chat.interrupted')}

- )} -
- ))} - {pendingCommand && ( -
-

- {t('chat.commandApprovalTitle')} + {loadError &&

{loadError}

} + {messages.map((message) => { + const isUser = message.role === 'user' + const isEditing = isUser && editingId === message.id + return ( +
+ {!isUser && message.thinking && ( + + )} + {!isUser && message.steps && message.steps.length > 0 && ( + + )} + {isUser ? ( + editingId === message.id ? ( +
+