commit f3a2fe38165095cd4326a056e34ff3c0ca5ecf38 Author: Richard Zink Date: Sun Nov 8 14:39:24 2026 +0100 Initial CodeCraft: web-based coding harness for AI models Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d0a7938 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +server/target +web/node_modules +web/dist +data +workspace +.env +.git diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e181950 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Access token for the web UI (required). Generate one with: openssl rand -hex 32 +CODECRAFT_TOKEN= + +# Directory with your projects, mounted as /workspace. +WORKSPACE=/home/zink/Projects + +# Host port. +PORT=8787 + +# UID/GID that owns the project files (id -u / id -g). +UID=1000 +GID=1000 + +# Provider keys – referenced in data/config.toml as ${NAME}. +ANTHROPIC_API_KEY= +GEMINI_API_KEY= +OPENCODE_API_KEY= +FAL_KEY= +OPENROUTER_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc2dddf --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/server/target/ +/web/node_modules/ +/web/dist/ +/data/ +/workspace/ +.env +*.db +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b99afa4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 + +# --- web UI (PWA) ------------------------------------------------------------- +FROM node:22-alpine AS web +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY web/ ./ +RUN npm run build + +# --- server ------------------------------------------------------------------- +FROM rust:1-bookworm AS server +WORKDIR /src +COPY config.example.toml ./ +COPY server/Cargo.toml server/Cargo.lock ./server/ +# Cache dependencies separately from the sources. +RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \ + && cd server && cargo build --release && rm -rf src +COPY server/src ./server/src +RUN cd server && touch src/main.rs && cargo build --release + +# --- runtime ------------------------------------------------------------------ +# Node is included so agents can work on JS projects and run npx-based MCP servers. +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates bash git curl ripgrep jq less procps \ + python3 python3-venv build-essential \ + chromium fonts-dejavu-core fonts-noto-color-emoji \ + && rm -rf /var/lib/apt/lists/* +COPY --from=server /src/server/target/release/codecraft /usr/local/bin/codecraft +COPY --from=web /web/dist /app/web +ENV CODECRAFT_WEB=/app/web \ + CODECRAFT_CONFIG=/data/config.toml \ + HOME=/data/home \ + RUST_LOG=info +RUN mkdir -p /data /workspace && chmod 777 /data /workspace +VOLUME ["/data"] +EXPOSE 8787 +# Run as the host user (see docker-compose.yml) so files in /workspace keep their owner. +USER 1000:1000 +ENTRYPOINT ["codecraft"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..67f3952 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# CodeCraft + +A self-hosted, web-based coding harness for AI models – in the spirit of Claude Code and +opencode, usable from any browser and installable as a PWA. + +- **Any model**: Anthropic (native Messages API), Google Gemini, opencode Go, fal.ai, + OpenRouter and self-hosted models (Ollama, vLLM, llama.cpp, LM Studio) through + OpenAI-compatible endpoints. +- **Agent loop with tools**: read, write, edit, list, glob, grep, bash, web_fetch, + todo_write, ask_user, task (subagents), skill – plus every tool of your MCP servers. +- **Modes**: `build` (full access), `plan` (read-only, proposes a plan), `craft` + (study a target → build your own version → check visually → refine), and custom + agents. +- **Human in the loop**: permission requests (with diffs) and questions from agents *and* + MCP servers (elicitation) are forwarded to the UI; optional browser notifications. +- **MCP**: stdio and Streamable HTTP servers, configured in the UI or the config file. +- **Statistics**: every model request is recorded (tokens in/out, cache, time to first + token, prefill and output speed, cost per model price) – per chat and in a dashboard + with daily charts and a weekday × hour heatmap. +- **Claude Code compatible project files**: `AGENTS.md`, `CLAUDE.md`, + `.claude/{agents,commands,skills}` (and `.codecraft/…`). +- **Configuration** in a commented TOML file that the web UI edits in place (comments + are preserved). +- **i18n**: English and German; adding a language is one file. + +## Quick start (Docker) + +```sh +cp .env.example .env # set CODECRAFT_TOKEN, WORKSPACE and provider keys +docker compose up -d --build +``` + +Open `http://:8787`, sign in with the token and pick a project directory. On first +start the commented default config is written to `data/config.toml`. Put the service behind +a TLS reverse proxy for remote access – the PWA install prompt requires HTTPS. + +## Configuration + +See [`config.example.toml`](config.example.toml) – every option is documented there. +Highlights: + +- `[providers.]` – `kind = "anthropic" | "openai"`, `base_url`, `api_key` + (`${ENV}` references recommended), optional `models` with context size, effort and + prices per 1M tokens. Without `models`, the list is fetched from the provider. +- `[mcp.]` – `type = "stdio"` (command/args/env) or `"http"` (url/headers). +- `[permissions]` – `allow` / `ask` / `deny` per tool (globs such as `mcp__*`), plus + `[permissions.bash_rules]` for shell command patterns. Compound commands are only + auto-allowed when every part is allowed. +- `[agents.]` – custom modes and subagents. +- `[craft]` – Chromium binary, screenshot widths, clean room vs. deep mode. + +## Development + +Requirements: Rust (edition 2024), Node ≥ 20.19. + +```sh +# backend (writes ./data/config.toml on first start) +cd server && CODECRAFT_CONFIG=../data/config.toml cargo run + +# frontend with hot reload, proxies /api to 127.0.0.1:8787 +cd web && npm install && npm run dev +``` + +For local development set `bind = "127.0.0.1:8787"` and `workspace` to your projects +directory in `data/config.toml`. + +Tests: `cd server && cargo test`, type check: `cd web && npx tsc --noEmit`. + +## Architecture + +``` +server/ (Rust, axum + tokio) + provider/ Anthropic + OpenAI-compatible streaming clients + runner.rs agent loop: streaming, tool execution, permissions, subagents, compaction + tools/ built-in tools (fs, bash, web, agent, craft) + mcp.rs MCP client (stdio, Streamable HTTP, elicitation) + prompts.rs agents/modes, system prompt, AGENTS.md, slash commands, skills + permission.rs, config.rs (toml + toml_edit), store.rs (SQLite), api.rs (REST + WebSocket) +web/ (React + Vite, PWA) + src/components chat, composer, permission/question cards, settings, statistics + src/i18n en (reference), de +``` + +The server streams all events over one WebSocket (`/api/ws`); the UI reconnects and +resyncs automatically, so long-running agents keep going when the browser is closed. + +## Roadmap + +- Background shell processes, file checkpoints/undo, `@file` mentions +- MCP OAuth, resources and prompts; remote MCP over SSE (legacy) +- Hooks (pre/post tool), output styles, per-project config overrides +- Image generation tools (fal.ai), OpenAI Responses API +- Multi-user accounts diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..cb10f18 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,208 @@ +# ============================================================================= +# CodeCraft – configuration +# ============================================================================= +# +# This file is the single source of truth for the server. Everything here can +# also be edited in the web UI (Settings). The UI edits this file in place and +# keeps your comments and formatting intact. +# +# Location: $CODECRAFT_CONFIG, otherwise /config.toml (Docker: /data/config.toml) +# +# Secrets: any string value may reference environment variables with ${NAME}, +# e.g. api_key = "${ANTHROPIC_API_KEY}". Prefer this over literal keys – keys +# entered in the UI are stored here in plain text. +# ============================================================================= + +[server] +# Address the HTTP server listens on. +bind = "0.0.0.0:8787" + +# Root directory the agents may work in. Sessions pick a project folder below it. +# Docker: mount your projects here (see docker-compose.yml). +workspace = "/workspace" + +# Where sessions (SQLite) and runtime state are stored. +data_dir = "/data" + +# Access token for the web UI and API. Leave empty to disable auth (only do +# this on localhost!). Recommended: auth_token = "${CODECRAFT_TOKEN}" +auth_token = "${CODECRAFT_TOKEN}" + +# Default model for new sessions, as "/". +default_model = "anthropic/claude-sonnet-5-5" + +# Cheap/fast model for titles and history compaction. Falls back to default_model. +small_model = "anthropic/claude-haiku-5-5" + +# Compact the conversation automatically when the context is this full (0.0–1.0). +auto_compact_threshold = 0.85 + +# Currency label for the cost statistics (prices below are in this currency). +currency = "USD" + + +# ----------------------------------------------------------------------------- +# Providers +# ----------------------------------------------------------------------------- +# kind = "anthropic" → Anthropic Messages API (and compatible endpoints) +# kind = "openai" → OpenAI-compatible /chat/completions (Gemini, opencode Go, +# fal, OpenRouter, Ollama, vLLM, llama.cpp, LM Studio, ...) +# +# models: optional list. If empty, the model list is fetched from /models. +# Per-model options: context (tokens), max_output (tokens), reasoning (bool), +# vision (bool, can read images), effort ("low" | "medium" | "high" | "xhigh" | "max"). +# +# Prices (per 1M tokens, used for the statistics): +# input_price, output_price, cache_read_price, cache_write_price +# Cache prices default to input_price when omitted. Costs are stored per request, +# so changing a price only affects future requests. +# +# Authentication: by default "Authorization: Bearer " (openai) or +# "x-api-key: " (anthropic). Override with auth_header / auth_prefix. +# Extra static headers: headers = { "X-Foo" = "bar" } +# ----------------------------------------------------------------------------- + +[providers.anthropic] +kind = "anthropic" +base_url = "https://api.anthropic.com/v1" +api_key = "${ANTHROPIC_API_KEY}" +# Current Claude models think adaptively; "effort" controls depth and cost. +# Prices: Anthropic list prices in USD per 1M tokens (Nov 2026) – adjust if needed. +models = [ + { id = "claude-opus-5-5", context = 1000000, max_output = 64000, reasoning = true, vision = true, effort = "high", + input_price = 4.0, output_price = 20.0, cache_read_price = 0.20, cache_write_price = 5.0 }, + { id = "claude-sonnet-5-5", context = 1000000, max_output = 64000, reasoning = true, vision = true, effort = "medium", + input_price = 2.0, output_price = 10.0, cache_read_price = 0.20, cache_write_price = 2.5 }, + { id = "claude-haiku-5-5", context = 1000000, max_output = 32000, reasoning = true, vision = true, effort = "low", + input_price = 0.10, output_price = 0.50, cache_read_price = 0.01, cache_write_price = 0.125 }, +] + +# Google Gemini through its OpenAI-compatible endpoint. +[providers.gemini] +kind = "openai" +base_url = "https://generativelanguage.googleapis.com/v1beta/openai" +api_key = "${GEMINI_API_KEY}" +models = [] # discovered automatically +enabled = false # set to true once GEMINI_API_KEY is available + +# opencode Go subscription (OpenAI-compatible endpoint for most models). +# Current model list: https://opencode.ai/inference/go/v1/models +[providers.opencode-go] +kind = "openai" +base_url = "https://opencode.ai/inference/go/openai/v1" +api_key = "${OPENCODE_API_KEY}" +models = [] +enabled = false + +# fal.ai – OpenRouter-backed LLM router. fal expects "Authorization: Key ". +# NOTE: verify base_url against the fal docs for your account. +[providers.fal] +kind = "openai" +base_url = "https://fal.run/openrouter/router/openai/v1" +api_key = "${FAL_KEY}" +auth_prefix = "Key " +models = [ + { id = "google/gemini-2.5-flash", context = 1000000, max_output = 32000, vision = true }, +] +enabled = false + +# Self-hosted (Ollama shown; vLLM/llama.cpp/LM Studio work the same way). +# From inside Docker, the host is reachable as host.docker.internal. +[providers.local] +kind = "openai" +base_url = "http://host.docker.internal:11434/v1" +api_key = "" +models = [] +enabled = false + + +# ----------------------------------------------------------------------------- +# MCP servers +# ----------------------------------------------------------------------------- +# type = "stdio" → command + args (+ env), started as a child process +# type = "http" → Streamable HTTP endpoint (url + optional headers) +# Tools appear to the model as mcp____. +# MCP servers may ask you questions (elicitation); they show up in the UI. +# ----------------------------------------------------------------------------- + +# [mcp.trilium] +# type = "http" +# url = "https://notes.example.org/mcp" +# headers = { Authorization = "Bearer ${TRILIUM_TOKEN}" } + +# [mcp.filesystem] +# type = "stdio" +# command = "npx" +# args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] +# env = {} +# enabled = false + + +# ----------------------------------------------------------------------------- +# Permissions +# ----------------------------------------------------------------------------- +# For each tool: "allow" | "ask" | "deny". "ask" forwards the request to the UI. +# MCP tools can be addressed as "mcp____" or "mcp____*". +# Session permission modes in the UI override this: +# default → these rules +# accept_edits → file edits allowed without asking +# bypass → everything allowed (use with care) +# ----------------------------------------------------------------------------- + +[permissions] +read = "allow" +glob = "allow" +grep = "allow" +list = "allow" +todo_write = "allow" +ask_user = "allow" +task = "allow" +write = "ask" +edit = "ask" +bash = "ask" +web_fetch = "ask" +browser_snapshot = "allow" +image_compare = "allow" +"mcp__*" = "ask" + +# Shell command patterns (glob, matched against the full command). First match wins. +[permissions.bash_rules] +"git status*" = "allow" +"git diff*" = "allow" +"git log*" = "allow" +"ls*" = "allow" +"pwd" = "allow" +"rm -rf /*" = "deny" + + +# ----------------------------------------------------------------------------- +# Agents (modes) +# ----------------------------------------------------------------------------- +# Built-in: build (full access), plan (read-only, proposes a plan), +# craft (study → build → check → ready), and subagents +# general + explore used by the "task" tool. +# Define your own or override built-ins here. mode = "primary" shows up in the +# mode switcher, mode = "subagent" is only available through the task tool. +# ----------------------------------------------------------------------------- + +# [agents.reviewer] +# mode = "subagent" +# description = "Reviews code changes for bugs and style issues." +# model = "anthropic/claude-opus-5-5" # optional, otherwise session model +# tools = ["read", "glob", "grep", "bash"] # optional, default: all tools +# prompt = """ +# You are a meticulous code reviewer. ... +# """ + + +# ----------------------------------------------------------------------------- +# Craft mode +# ----------------------------------------------------------------------------- +[craft] +# Headless Chromium binary used to study websites and check builds. +chromium = "chromium" +# Screen widths used for screenshots and comparisons. +widths = [390, 768, 1440] +# Clean room: only what a visitor sees (screenshots, visible text, layout). +# Set to false for "deep" mode (page source and scripts may be read). +clean_room = true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8b628ad --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +# CodeCraft – copy .env.example to .env and adjust, then: docker compose up -d --build +services: + codecraft: + build: . + image: codecraft:latest + container_name: codecraft + restart: unless-stopped + # Same UID/GID as the owner of your project files. + user: "${UID:-1000}:${GID:-1000}" + ports: + - "${PORT:-8787}:8787" + environment: + CODECRAFT_TOKEN: ${CODECRAFT_TOKEN:?set CODECRAFT_TOKEN in .env} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + OPENCODE_API_KEY: ${OPENCODE_API_KEY:-} + FAL_KEY: ${FAL_KEY:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + volumes: + # Config (config.toml), SQLite database, agent home directory. + - ./data:/data + # The projects the agents may work on. + - ${WORKSPACE:-./workspace}:/workspace + # Reach self-hosted models (Ollama, vLLM, ...) on the host. + extra_hosts: + - "host.docker.internal:host-gateway" + shm_size: "1gb" # Chromium (craft mode) diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..1e0f2bd --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,2968 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "codecraft" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "chrono", + "eventsource-stream", + "futures", + "globset", + "html2text", + "ignore", + "image", + "libc", + "regex", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "shell-words", + "similar", + "tokio", + "tokio-util", + "toml", + "toml_edit", + "tower-http 0.7.1", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "eventsource-stream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" +dependencies = [ + "futures-core", + "nom", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "globset" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98b0d85522dc0a824cfebbc9594ff4a9b289c568bea0370319dfbdf20ccdd2d" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "html2text" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389acaa5a86952dcdc88a43bc18021009cb9885b0ff8c2fff1cc44e4f34c2fae" +dependencies = [ + "html5ever", + "tendril", + "thiserror", + "unicode-width", +] + +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484be5657551d7868d67587df967fb4fcbe9d266a761cc855aaa148fda0b3cc7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libsqlite3-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.8", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" +dependencies = [ + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.41.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618" +dependencies = [ + "cc", + "rsqlite-vfs", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.2.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.19+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a06f08a856b009bc7bbaf7d3e79808595d032dd19074c0c2ef26e49e03e6bdc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..01d5375 --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "codecraft" +version = "0.1.0" +edition = "2024" +license = "MIT" +description = "Web-based coding harness for AI models" + +[dependencies] +anyhow = "1" +async-trait = "0.1" +axum = { version = "0.8", features = ["ws", "macros"] } +bytes = "1" +chrono = { version = "0.4", features = ["serde"] } +eventsource-stream = "0.2" +futures = "0.3" +globset = "0.4" +html2text = "0.15" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +base64 = "0.22" +shell-words = "1" +libc = "0.2" +ignore = "0.4" +regex = "1" +reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] } +rusqlite = { version = "0.41", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +similar = "2" +tokio = { version = "1", features = ["full"] } +tokio-util = "0.7" +toml = "1" +toml_edit = "0.25" +tower-http = { version = "0.7", features = ["fs", "cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1", features = ["v4", "v7"] } diff --git a/server/src/api.rs b/server/src/api.rs new file mode 100644 index 0000000..a545657 --- /dev/null +++ b/server/src/api.rs @@ -0,0 +1,524 @@ +//! HTTP + WebSocket API. + +use crate::app::{App, PermissionReply}; +use crate::config::{self, ConfigPatch}; +use crate::prompts; +use crate::runner; +use crate::types::{Event, ImageData, Part, Session, new_id, now_ms}; +use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, Query, Request, State}; +use axum::http::{StatusCode, header}; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use std::path::PathBuf; +use std::sync::Arc; + +type AppState = Arc; + +pub struct ApiError(StatusCode, String); + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.0, Json(json!({"error": self.1}))).into_response() + } +} + +impl> From for ApiError { + fn from(e: E) -> Self { + ApiError(StatusCode::BAD_REQUEST, format!("{:#}", e.into())) + } +} + +type ApiResult> = Result; + +fn not_found(what: &str) -> ApiError { + ApiError(StatusCode::NOT_FOUND, format!("{what} not found")) +} + +pub fn router(app: AppState) -> Router { + let api = Router::new() + .route("/info", get(info)) + .route("/config", get(get_config).patch(patch_config)) + .route("/config/raw", get(get_config_raw).put(put_config_raw)) + .route("/providers/reload", post(reload_providers)) + .route("/models", get(models)) + .route("/agents", get(agents)) + .route("/commands", get(commands)) + .route("/sessions", get(list_sessions).post(create_session)) + .route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session)) + .route("/sessions/{id}/prompt", post(prompt)) + .route("/sessions/{id}/abort", post(abort)) + .route("/sessions/{id}/compact", post(compact)) + .route("/sessions/{id}/rewind", post(rewind)) + .route("/sessions/{id}/stats", get(session_stats)) + .route("/permissions/{id}", post(reply_permission)) + .route("/questions/{id}", post(answer_question)) + .route("/pending", get(pending)) + .route("/mcp", get(mcp_status)) + .route("/mcp/reload", post(mcp_reload)) + .route("/fs/dirs", get(list_dirs)) + .route("/stats", get(stats)) + .route("/ws", get(ws)) + .layer(middleware::from_fn_with_state(app.clone(), auth)); + + let web_dir = std::env::var("CODECRAFT_WEB").map(PathBuf::from).unwrap_or_else(|_| { + ["../web/dist", "./web/dist", "/app/web"] + .iter() + .map(PathBuf::from) + .find(|p| p.join("index.html").exists()) + .unwrap_or_else(|| PathBuf::from("./web/dist")) + }); + let index = web_dir.join("index.html"); + let static_files = tower_http::services::ServeDir::new(&web_dir) + .fallback(tower_http::services::ServeFile::new(index)); + + Router::new().nest("/api", api).fallback_service(static_files).with_state(app) +} + +/// Bearer token, `token` query parameter (WebSocket) or `codecraft_token` cookie. +async fn auth(State(app): State, req: Request, next: Next) -> Response { + let expected = app.config().auth_token(); + if expected.is_empty() || req.uri().path().ends_with("/info") { + return next.run(req).await; + } + let header_token = req + .headers() + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(String::from); + let query_token = req.uri().query().and_then(|q| { + q.split('&').find_map(|kv| kv.strip_prefix("token=")).map(String::from) + }); + let cookie_token = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|c| c.split(';').find_map(|kv| kv.trim().strip_prefix("codecraft_token=")).map(String::from)); + let ok = [header_token, query_token, cookie_token] + .into_iter() + .flatten() + .any(|t| constant_time_eq(t.as_bytes(), expected.as_bytes())); + if ok { + next.run(req).await + } else { + ApiError(StatusCode::UNAUTHORIZED, "unauthorized".into()).into_response() + } +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +// --- info & config ---------------------------------------------------------- + +async fn info(State(app): State) -> Json { + let cfg = app.config(); + Json(json!({ + "name": "CodeCraft", + "version": env!("CARGO_PKG_VERSION"), + "auth_required": !cfg.auth_token().is_empty(), + })) +} + +async fn get_config(State(app): State) -> ApiResult { + let cfg = app.config(); + Ok(Json(json!({ + "config": config::redacted_json(&cfg), + "workspace": app.workspace(), + "default_model": if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) }, + "currency": cfg.server.currency, + }))) +} + +#[derive(Deserialize)] +struct PatchBody { + patches: Vec, +} + +async fn patch_config(State(app): State, Json(body): Json) -> ApiResult { + // Masked secrets coming back from the UI must not overwrite real values. + let patches: Vec = body.patches.into_iter().filter(|p| p.value.as_str() != Some("••••••••")).collect(); + app.patch_config(&patches).await?; + Ok(Json(json!({"ok": true}))) +} + +async fn get_config_raw(State(app): State) -> ApiResult { + Ok(Json(json!({"text": app.config_text()?}))) +} + +#[derive(Deserialize)] +struct RawBody { + text: String, +} + +async fn put_config_raw(State(app): State, Json(body): Json) -> ApiResult { + app.save_config(&body.text).await?; + Ok(Json(json!({"ok": true}))) +} + +async fn reload_providers(State(app): State) -> ApiResult { + app.reload_providers().await; + Ok(Json(app.registry().models_json())) +} + +async fn models(State(app): State) -> Json { + Json(app.registry().models_json()) +} + +#[derive(Deserialize)] +struct CwdQuery { + cwd: Option, +} + +fn cwd_or_workspace(app: &App, cwd: Option) -> PathBuf { + cwd.map(PathBuf::from).unwrap_or_else(|| app.workspace()) +} + +async fn agents(State(app): State, Query(q): Query) -> Json { + let cwd = cwd_or_workspace(&app, q.cwd); + Json(json!(prompts::agents(&app, &cwd))) +} + +async fn commands(State(app): State, Query(q): Query) -> Json { + let cwd = cwd_or_workspace(&app, q.cwd); + Json(json!({ + "commands": prompts::commands(&app, &cwd), + "skills": prompts::skills(&app, &cwd), + })) +} + +// --- sessions --------------------------------------------------------------- + +async fn list_sessions(State(app): State) -> ApiResult { + let sessions = app.store.list_sessions()?; + let list: Vec = sessions + .into_iter() + .map(|s| { + let busy = app.is_busy(&s.id); + let mut v = json!(s); + v["busy"] = json!(busy); + v + }) + .collect(); + Ok(Json(json!(list))) +} + +#[derive(Deserialize)] +struct CreateSession { + cwd: Option, + model: Option, + agent: Option, + permission_mode: Option, +} + +fn validate_cwd(app: &App, cwd: Option) -> Result { + let ws = app.workspace(); + let p = match cwd.filter(|c| !c.trim().is_empty()) { + Some(c) if std::path::Path::new(&c).is_absolute() => PathBuf::from(c), + Some(c) => ws.join(c), + None => ws.clone(), + }; + let p = std::fs::canonicalize(&p).map_err(|_| ApiError(StatusCode::BAD_REQUEST, format!("directory does not exist: {}", p.display())))?; + if !p.starts_with(&ws) || !p.is_dir() { + return Err(ApiError(StatusCode::BAD_REQUEST, "directory must be inside the workspace".into())); + } + Ok(p) +} + +async fn create_session(State(app): State, Json(body): Json) -> ApiResult { + let cwd = validate_cwd(&app, body.cwd)?; + let cfg = app.config(); + let model = body + .model + .filter(|m| !m.is_empty()) + .or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())) + .or_else(|| app.registry().first_model()) + .ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "no model configured – add a provider in the settings".into()))?; + let now = now_ms(); + let s = Session { + id: new_id(), + title: String::new(), + cwd: cwd.display().to_string(), + model, + agent: body.agent.unwrap_or_else(|| "build".into()), + permission_mode: body.permission_mode.unwrap_or_else(|| "default".into()), + parent_id: None, + created_at: now, + updated_at: now, + todos: Value::Null, + last_usage: None, + }; + let s = app.save_session(s)?; + Ok(Json(json!(s))) +} + +async fn get_session(State(app): State, Path(id): Path) -> ApiResult { + let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?; + let messages = app.store.messages(&id)?; + let busy = app.is_busy(&id); + Ok(Json(json!({"session": s, "messages": messages, "busy": busy}))) +} + +#[derive(Deserialize)] +struct UpdateSession { + title: Option, + model: Option, + agent: Option, + permission_mode: Option, +} + +async fn update_session(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + let mut s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?; + if let Some(t) = body.title { + s.title = t; + } + if let Some(m) = body.model { + app.registry().resolve(&m)?; + s.model = m; + } + if let Some(a) = body.agent { + s.agent = a; + } + if let Some(p) = body.permission_mode { + if !["default", "accept_edits", "bypass"].contains(&p.as_str()) { + return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); + } + s.permission_mode = p; + } + Ok(Json(json!(app.save_session(s)?))) +} + +async fn delete_session(State(app): State, Path(id): Path) -> ApiResult { + runner::abort(&app, &id); + app.store.delete_session(&id)?; + app.emit(Event::SessionDeleted { session_id: id }); + Ok(Json(json!({"ok": true}))) +} + +#[derive(Deserialize)] +struct PromptBody { + text: String, + #[serde(default)] + images: Vec, +} + +async fn prompt(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + let session = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?; + let text = body.text.trim().to_string(); + // Built-in commands. + if text == "/compact" { + if app.is_busy(&id) { + return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); + } + tokio::spawn(do_compact(app.clone(), id.clone())); + return Ok(Json(json!({"ok": true, "command": "compact"}))); + } + let cwd = PathBuf::from(&session.cwd); + let mut prompt_text = text.clone(); + if text.starts_with('/') { + if let Some((expanded, agent)) = prompts::expand_command(&app, &cwd, &text) { + prompt_text = expanded; + if let Some(a) = agent { + let mut s = session.clone(); + s.agent = a; + app.save_session(s)?; + } + } + } + let mut parts = vec![Part::Text { text: prompt_text }]; + for image in body.images { + parts.push(Part::Image { image }); + } + runner::start_prompt(app.clone(), id, parts)?; + Ok(Json(json!({"ok": true}))) +} + +async fn do_compact(app: AppState, id: String) { + let cancel = tokio_util::sync::CancellationToken::new(); + app.with_runtime(&id, |r| r.cancel = Some(cancel.clone())); + app.emit(Event::SessionStatus { session_id: id.clone(), busy: true }); + if let Err(e) = runner::compact(app.clone(), &id, cancel).await { + app.emit(Event::Error { session_id: id.clone(), message: format!("compaction failed: {e:#}") }); + } + app.with_runtime(&id, |r| r.cancel = None); + app.emit(Event::SessionStatus { session_id: id, busy: false }); +} + +async fn abort(State(app): State, Path(id): Path) -> Json { + runner::abort(&app, &id); + Json(json!({"ok": true})) +} + +async fn compact(State(app): State, Path(id): Path) -> ApiResult { + if app.is_busy(&id) { + return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); + } + tokio::spawn(do_compact(app.clone(), id)); + Ok(Json(json!({"ok": true}))) +} + +#[derive(Deserialize)] +struct RewindBody { + message_id: String, +} + +/// Remove a message and everything after it (edit & resend). Files are not restored. +async fn rewind(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + if app.is_busy(&id) { + return Err(ApiError(StatusCode::CONFLICT, "session is busy".into())); + } + app.store.truncate_from(&id, &body.message_id)?; + let s = app.store.get_session(&id)?.ok_or_else(|| not_found("session"))?; + let mut s = s; + s.last_usage = None; + app.save_session(s)?; + Ok(Json(json!({"ok": true}))) +} + +async fn session_stats(State(app): State, Path(id): Path) -> ApiResult { + let mut v = app.store.session_stats(&id)?; + v["currency"] = json!(app.config().server.currency); + Ok(Json(v)) +} + +// --- interaction ------------------------------------------------------------ + +#[derive(Deserialize)] +struct PermissionBody { + reply: PermissionReply, + message: Option, +} + +async fn reply_permission(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + app.reply_permission(&id, body.reply, body.message)?; + Ok(Json(json!({"ok": true}))) +} + +#[derive(Deserialize)] +struct QuestionBody { + /// question text → answer (string or array of strings); null = dismissed + answers: Option>, +} + +async fn answer_question(State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { + app.answer_question(&id, body.answers)?; + Ok(Json(json!({"ok": true}))) +} + +async fn pending(State(app): State) -> Json { + Json(json!(app.pending_events())) +} + +async fn mcp_status(State(app): State) -> Json { + Json(app.mcp.status_json()) +} + +async fn mcp_reload(State(app): State) -> Json { + let mcp = app.mcp.clone(); + let servers = app.config().mcp.clone(); + let weak = Arc::downgrade(&app); + mcp.start_all(weak, servers).await; + Json(app.mcp.status_json()) +} + +// --- files ------------------------------------------------------------------ + +#[derive(Deserialize)] +struct DirQuery { + path: Option, +} + +/// Subdirectories for the project picker (inside the workspace only). +async fn list_dirs(State(app): State, Query(q): Query) -> ApiResult { + let dir = validate_cwd(&app, q.path)?; + let mut dirs = Vec::new(); + for e in std::fs::read_dir(&dir)?.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || !e.file_type().map(|t| t.is_dir()).unwrap_or(false) { + continue; + } + dirs.push(json!({"name": name, "path": e.path(), "git": e.path().join(".git").exists()})); + } + dirs.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + let ws = app.workspace(); + Ok(Json(json!({ + "path": dir, + "parent": if dir == ws { Value::Null } else { json!(dir.parent()) }, + "workspace": ws, + "dirs": dirs, + }))) +} + +// --- statistics ------------------------------------------------------------- + +#[derive(Deserialize)] +struct StatsQuery { + from: Option, + to: Option, + /// Minutes east of UTC (JS: -new Date().getTimezoneOffset()). + tz: Option, +} + +async fn stats(State(app): State, Query(q): Query) -> ApiResult { + let to = q.to.unwrap_or_else(|| now_ms() + 1); + let from = q.from.unwrap_or(to - 30 * 86_400_000); + let mut v = app.store.global_stats(from, to, q.tz.unwrap_or(0))?; + v["currency"] = json!(app.config().server.currency); + v["from"] = json!(from); + v["to"] = json!(to); + Ok(Json(v)) +} + +// --- websocket -------------------------------------------------------------- + +async fn ws(State(app): State, upgrade: WebSocketUpgrade) -> Response { + upgrade.on_upgrade(move |socket| ws_loop(app, socket)) +} + +async fn ws_loop(app: AppState, mut socket: WebSocket) { + let mut rx = app.subscribe(); + for ev in app.pending_events() { + if send_event(&mut socket, &ev).await.is_err() { + return; + } + } + let mut ping = tokio::time::interval(std::time::Duration::from_secs(25)); + loop { + tokio::select! { + ev = rx.recv() => match ev { + Ok(ev) => { + if send_event(&mut socket, &ev).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + // Client fell behind: tell it to resync. + let msg = json!({"type": "resync", "skipped": n}).to_string(); + if socket.send(WsMessage::Text(msg.into())).await.is_err() { + break; + } + } + Err(_) => break, + }, + msg = socket.recv() => match msg { + Some(Ok(WsMessage::Close(_))) | None | Some(Err(_)) => break, + _ => {} + }, + _ = ping.tick() => { + if socket.send(WsMessage::Ping(Vec::new().into())).await.is_err() { + break; + } + } + } + } +} + +async fn send_event(socket: &mut WebSocket, ev: &Event) -> Result<(), axum::Error> { + let text = serde_json::to_string(ev).unwrap_or_default(); + socket.send(WsMessage::Text(text.into())).await +} diff --git a/server/src/app.rs b/server/src/app.rs new file mode 100644 index 0000000..692d721 --- /dev/null +++ b/server/src/app.rs @@ -0,0 +1,274 @@ +//! Shared application state. + +use crate::config::{self, Config}; +use crate::mcp::McpManager; +use crate::provider::Registry; +use crate::store::Store; +use crate::types::{Event, Question, Session, now_ms}; +use anyhow::{Result, anyhow}; +use serde_json::{Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; +use tokio::sync::{broadcast, oneshot}; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PermissionReply { + /// Allow this one call. + Once, + /// Allow this tool (or bash command prefix) for the rest of the session. + Always, + Reject, +} + +struct PendingPermission { + event: Event, + tx: oneshot::Sender<(PermissionReply, Option)>, +} + +struct PendingQuestion { + event: Event, + tx: oneshot::Sender>>, +} + +/// Per-session runtime state (not persisted). +#[derive(Default)] +pub struct Runtime { + pub cancel: Option, + pub read_files: HashSet, + pub approved: HashSet, + /// Prompts sent while the agent was busy; injected at the next step. + pub queue: Vec, +} + +pub struct App { + config_path: PathBuf, + config: RwLock>, + registry: RwLock>, + pub store: Store, + events: broadcast::Sender, + pub mcp: Arc, + runtime: Mutex>, + permissions: Mutex>, + questions: Mutex>, +} + +impl App { + pub async fn new(config_path: PathBuf, cfg: Config) -> Result> { + let data = cfg.data_dir(); + std::fs::create_dir_all(&data)?; + let store = Store::open(&data.join("codecraft.db"))?; + let registry = Registry::build(&cfg).await; + let (events, _) = broadcast::channel(4096); + let app = Arc::new(Self { + config_path, + config: RwLock::new(Arc::new(cfg)), + registry: RwLock::new(Arc::new(registry)), + store, + events, + mcp: Arc::new(McpManager::default()), + runtime: Mutex::default(), + permissions: Mutex::default(), + questions: Mutex::default(), + }); + app.clone().start_mcp(); + Ok(app) + } + + fn start_mcp(self: Arc) { + let servers = self.config().mcp.clone(); + let mcp = self.mcp.clone(); + let weak = Arc::downgrade(&self); + tokio::spawn(async move { mcp.start_all(weak, servers).await }); + } + + pub fn config(&self) -> Arc { + self.config.read().unwrap().clone() + } + + pub fn registry(&self) -> Arc { + self.registry.read().unwrap().clone() + } + + pub fn workspace(&self) -> PathBuf { + let ws = self.config().workspace(); + std::fs::canonicalize(&ws).unwrap_or(ws) + } + + pub fn data_dir(&self) -> PathBuf { + self.config().data_dir() + } + + pub fn config_text(&self) -> Result { + Ok(std::fs::read_to_string(&self.config_path)?) + } + + /// Validate, write and apply a new config text. Providers and MCP servers reload. + pub async fn save_config(self: &Arc, text: &str) -> Result<()> { + let cfg = Config::parse(text)?; + let tmp = self.config_path.with_extension("toml.tmp"); + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, &self.config_path)?; + let registry = Registry::build(&cfg).await; + let mcp_changed = serde_json::to_value(&cfg.mcp)? != serde_json::to_value(&self.config().mcp)?; + *self.config.write().unwrap() = Arc::new(cfg); + *self.registry.write().unwrap() = Arc::new(registry); + if mcp_changed { + self.clone().start_mcp(); + } + Ok(()) + } + + pub async fn patch_config(self: &Arc, patches: &[config::ConfigPatch]) -> Result<()> { + let text = config::apply_patches(&self.config_text()?, patches)?; + self.save_config(&text).await + } + + pub async fn reload_providers(&self) { + let registry = Registry::build(&self.config()).await; + *self.registry.write().unwrap() = Arc::new(registry); + } + + // --- events --------------------------------------------------------- + + pub fn emit(&self, ev: Event) { + let _ = self.events.send(ev); + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + /// Pending permission and question requests, re-sent to newly connected clients. + pub fn pending_events(&self) -> Vec { + let mut out: Vec = self.permissions.lock().unwrap().values().map(|p| p.event.clone()).collect(); + out.extend(self.questions.lock().unwrap().values().map(|q| q.event.clone())); + out + } + + // --- runtime -------------------------------------------------------- + + pub fn with_runtime(&self, session_id: &str, f: impl FnOnce(&mut Runtime) -> R) -> R { + let mut rt = self.runtime.lock().unwrap(); + f(rt.entry(session_id.to_string()).or_default()) + } + + pub fn is_busy(&self, session_id: &str) -> bool { + self.with_runtime(session_id, |r| r.cancel.is_some()) + } + + pub fn mark_read(&self, session_id: &str, path: &Path) { + self.with_runtime(session_id, |r| r.read_files.insert(path.to_path_buf())); + } + + pub fn was_read(&self, session_id: &str, path: &Path) -> bool { + self.with_runtime(session_id, |r| r.read_files.contains(path)) + } + + pub fn session(&self, id: &str) -> Result { + self.store.get_session(id)?.ok_or_else(|| anyhow!("session not found")) + } + + pub fn save_session(&self, mut s: Session) -> Result { + s.updated_at = now_ms(); + self.store.save_session(&s)?; + self.emit(Event::SessionUpdated { session: s.clone() }); + Ok(s) + } + + pub fn set_todos(&self, session_id: &str, todos: Value) -> Result<()> { + let mut s = self.session(session_id)?; + s.todos = todos; + self.save_session(s)?; + Ok(()) + } + + /// Walk up the parent chain to the top-level session. + pub fn root_session_id(&self, session_id: &str) -> String { + let mut id = session_id.to_string(); + for _ in 0..16 { + match self.store.get_session(&id).ok().flatten().and_then(|s| s.parent_id) { + Some(p) => id = p, + None => break, + } + } + id + } + + // --- user interaction ------------------------------------------------- + + /// Forward questions to the UI and wait. None = dismissed. + pub async fn ask_user( + &self, + session_id: &str, + source: &str, + questions: Vec, + cancel: CancellationToken, + ) -> Result>> { + let id = crate::types::new_id(); + let event = Event::QuestionRequest { + id: id.clone(), + session_id: session_id.to_string(), + root_session_id: self.root_session_id(session_id), + source: source.to_string(), + questions, + }; + let (tx, rx) = oneshot::channel(); + self.questions.lock().unwrap().insert(id.clone(), PendingQuestion { event: event.clone(), tx }); + self.emit(event); + let res = tokio::select! { + r = rx => r.unwrap_or(None), + _ = cancel.cancelled() => None, + }; + self.questions.lock().unwrap().remove(&id); + self.emit(Event::QuestionResolved { id, session_id: session_id.to_string() }); + Ok(res) + } + + pub fn answer_question(&self, id: &str, answers: Option>) -> Result<()> { + let p = self.questions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("question not pending"))?; + let _ = p.tx.send(answers); + Ok(()) + } + + /// Ask the user to approve a tool call. Returns the reply and an optional + /// message (feedback on rejection). + pub async fn request_permission( + &self, + session_id: &str, + tool: &str, + title: String, + input: Value, + diff: Option, + cancel: CancellationToken, + ) -> (PermissionReply, Option) { + let id = crate::types::new_id(); + let event = Event::PermissionRequest { + id: id.clone(), + session_id: session_id.to_string(), + root_session_id: self.root_session_id(session_id), + tool: tool.to_string(), + title, + input, + diff, + }; + let (tx, rx) = oneshot::channel(); + self.permissions.lock().unwrap().insert(id.clone(), PendingPermission { event: event.clone(), tx }); + self.emit(event); + let res = tokio::select! { + r = rx => r.unwrap_or((PermissionReply::Reject, None)), + _ = cancel.cancelled() => (PermissionReply::Reject, Some("aborted".into())), + }; + self.permissions.lock().unwrap().remove(&id); + self.emit(Event::PermissionResolved { id, session_id: session_id.to_string() }); + res + } + + pub fn reply_permission(&self, id: &str, reply: PermissionReply, message: Option) -> Result<()> { + let p = self.permissions.lock().unwrap().remove(id).ok_or_else(|| anyhow!("permission not pending"))?; + let _ = p.tx.send((reply, message)); + Ok(()) + } +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..b5d95b0 --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,424 @@ +//! Configuration: a commented TOML file that the web UI edits in place. +//! +//! Parsing goes through `toml` into typed structs; writes go through +//! `toml_edit` so comments and formatting survive UI edits. + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml"); + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[serde(default)] +pub struct Config { + pub server: ServerConfig, + pub providers: BTreeMap, + pub mcp: BTreeMap, + pub permissions: PermissionConfig, + pub agents: BTreeMap, + pub craft: CraftConfig, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct ServerConfig { + pub bind: String, + pub workspace: String, + pub data_dir: String, + pub auth_token: String, + pub default_model: String, + pub small_model: String, + pub auto_compact_threshold: f64, + /// Currency label for cost statistics (display only). + pub currency: String, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + bind: "127.0.0.1:8787".into(), + workspace: ".".into(), + data_dir: "./data".into(), + auth_token: String::new(), + default_model: String::new(), + small_model: String::new(), + auto_compact_threshold: 0.85, + currency: "USD".into(), + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProviderKind { + Anthropic, + Openai, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ProviderConfig { + pub kind: ProviderKind, + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub api_key: String, + #[serde(default)] + pub auth_header: Option, + #[serde(default)] + pub auth_prefix: Option, + #[serde(default)] + pub headers: BTreeMap, + #[serde(default)] + pub models: Vec, + #[serde(default = "yes")] + pub enabled: bool, +} + +fn yes() -> bool { + true +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum ModelEntry { + Id(String), + Full(ModelConfig), +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct ModelConfig { + pub id: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub context: Option, + #[serde(default)] + pub max_output: Option, + #[serde(default)] + pub reasoning: bool, + #[serde(default)] + pub vision: bool, + /// Reasoning effort (Anthropic: low|medium|high|xhigh|max, OpenAI: low|medium|high). + #[serde(default)] + pub effort: Option, + /// Prices in currency units per 1M tokens (used for cost statistics). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_price: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_price: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_read_price: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_write_price: Option, +} + +impl ModelConfig { + /// Cost of one request. Cache prices default to the input price. + pub fn cost(&self, u: &crate::types::Usage) -> f64 { + let inp = self.input_price.unwrap_or(0.0); + let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0; + per(u.input_tokens, inp) + + per(u.output_tokens, self.output_price.unwrap_or(0.0)) + + per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp)) + + per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp)) + } +} + +impl ModelEntry { + pub fn resolved(&self) -> ModelConfig { + match self { + ModelEntry::Id(id) => ModelConfig { id: id.clone(), ..Default::default() }, + ModelEntry::Full(m) => m.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct McpConfig { + #[serde(rename = "type")] + pub kind: McpKind, + #[serde(default)] + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: BTreeMap, + #[serde(default)] + pub url: String, + #[serde(default)] + pub headers: BTreeMap, + #[serde(default = "yes")] + pub enabled: bool, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum McpKind { + Stdio, + Http, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct PermissionConfig { + #[serde(default)] + pub bash_rules: BTreeMap, + #[serde(flatten)] + pub tools: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +pub struct AgentConfig { + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub tools: Option>, + #[serde(default)] + pub prompt: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct CraftConfig { + pub chromium: String, + pub widths: Vec, + pub clean_room: bool, +} + +impl Default for CraftConfig { + fn default() -> Self { + Self { chromium: "chromium".into(), widths: vec![390, 768, 1440], clean_room: true } + } +} + +/// Replace `${NAME}` with the environment variable (empty if unset). +pub fn expand_env(s: &str) -> String { + let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap(); + re.replace_all(s, |c: ®ex::Captures| std::env::var(&c[1]).unwrap_or_default()) + .into_owned() +} + +impl Config { + pub fn parse(text: &str) -> Result { + let cfg: Config = toml::from_str(text).context("invalid config")?; + for (name, p) in &cfg.providers { + if name.contains('/') { + bail!("provider name must not contain '/': {name}"); + } + if p.base_url.is_empty() && p.kind == ProviderKind::Openai { + bail!("provider {name}: base_url is required"); + } + } + Ok(cfg) + } + + pub fn workspace(&self) -> PathBuf { + PathBuf::from(expand_env(&self.server.workspace)) + } + + pub fn data_dir(&self) -> PathBuf { + PathBuf::from(expand_env(&self.server.data_dir)) + } + + pub fn auth_token(&self) -> String { + expand_env(&self.server.auth_token) + } +} + +/// Locate the config file: $CODECRAFT_CONFIG, else ./data/config.toml (or /data in Docker). +pub fn config_path() -> PathBuf { + if let Ok(p) = std::env::var("CODECRAFT_CONFIG") { + return PathBuf::from(p); + } + if Path::new("/data").is_dir() { + return PathBuf::from("/data/config.toml"); + } + PathBuf::from("./data/config.toml") +} + +/// Load the config, writing the commented example on first start. +pub fn load_or_init(path: &Path) -> Result<(Config, String)> { + if !path.exists() { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, EXAMPLE_CONFIG)?; + tracing::info!("wrote default config to {}", path.display()); + } + let text = std::fs::read_to_string(path)?; + let cfg = Config::parse(&text)?; + Ok((cfg, text)) +} + +/// A structured edit coming from the UI. Applied with toml_edit so comments survive. +#[derive(Debug, Deserialize)] +pub struct ConfigPatch { + /// Dotted path into the document, e.g. ["providers", "gemini", "enabled"]. + pub path: Vec, + /// New value as JSON; `null` removes the key. + pub value: serde_json::Value, +} + +pub fn apply_patches(text: &str, patches: &[ConfigPatch]) -> Result { + let mut doc: toml_edit::DocumentMut = text.parse().context("config is not valid TOML")?; + for patch in patches { + if patch.path.is_empty() { + bail!("empty patch path"); + } + let (last, parents) = patch.path.split_last().unwrap(); + let mut table: &mut toml_edit::Table = doc.as_table_mut(); + for key in parents { + let entry = table + .entry(key) + .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new())); + // Inline tables are converted so we can descend into them. + if let Some(inline) = entry.as_inline_table().cloned() { + *entry = toml_edit::Item::Table(inline.into_table()); + } + table = entry + .as_table_mut() + .with_context(|| format!("'{key}' is not a table"))?; + } + if patch.value.is_null() { + table.remove(last); + } else if patch.value.is_object() && parents.len() < 2 && table.get(last).is_none_or(|i| i.is_table()) { + // New sections (e.g. [providers.foo]) become real tables, not inline ones. + let mut t = toml_edit::Table::new(); + for (k, v) in patch.value.as_object().unwrap() { + if let Some(v) = json_to_toml(v) { + t.insert(k, toml_edit::Item::Value(v)); + } + } + table.insert(last, toml_edit::Item::Table(t)); + } else { + let v = json_to_toml(&patch.value).context("unsupported value")?; + match table.get_mut(last) { + // Keep the existing decoration (trailing comments) of the value. + Some(toml_edit::Item::Value(old)) => { + let decor = old.decor().clone(); + *old = v; + *old.decor_mut() = decor; + } + _ => { + table.insert(last, toml_edit::Item::Value(v)); + } + } + } + } + let out = doc.to_string(); + Config::parse(&out)?; + Ok(out) +} + +fn json_to_toml(v: &serde_json::Value) -> Option { + use serde_json::Value as J; + Some(match v { + J::Null => return None, + J::Bool(b) => (*b).into(), + J::Number(n) => { + if let Some(i) = n.as_i64() { + i.into() + } else { + n.as_f64()?.into() + } + } + J::String(s) => s.as_str().into(), + J::Array(a) => { + let mut arr = toml_edit::Array::new(); + for x in a { + if let Some(t) = json_to_toml(x) { + arr.push(t); + } + } + if a.iter().any(|x| x.is_object()) { + arr.set_trailing_comma(true); + for item in arr.iter_mut() { + item.decor_mut().set_prefix("\n "); + } + arr.set_trailing("\n"); + } + toml_edit::Value::Array(arr) + } + J::Object(o) => { + let mut t = toml_edit::InlineTable::new(); + for (k, x) in o { + if let Some(tv) = json_to_toml(x) { + t.insert(k, tv); + } + } + toml_edit::Value::InlineTable(t) + } + }) +} + +/// JSON view of the config for the UI. Secret values are masked unless they are +/// env references, so the UI can show "${FOO}" but never a literal key. +pub fn redacted_json(cfg: &Config) -> serde_json::Value { + let mut v = serde_json::to_value(cfg).unwrap_or_default(); + let mask = |s: &mut serde_json::Value| { + if let Some(text) = s.as_str() { + if !text.is_empty() && !text.starts_with("${") { + *s = serde_json::Value::String("••••••••".into()); + } + } + }; + if let Some(ps) = v.get_mut("providers").and_then(|p| p.as_object_mut()) { + for p in ps.values_mut() { + if let Some(k) = p.get_mut("api_key") { + mask(k); + } + } + } + if let Some(t) = v.pointer_mut("/server/auth_token") { + mask(t); + } + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn example_parses() { + let cfg = Config::parse(EXAMPLE_CONFIG).unwrap(); + assert!(cfg.providers.contains_key("anthropic")); + assert_eq!(cfg.permissions.tools.get("read").map(String::as_str), Some("allow")); + assert!(cfg.permissions.bash_rules.contains_key("git status*")); + } + + #[test] + fn patch_keeps_comments() { + let out = apply_patches( + EXAMPLE_CONFIG, + &[ConfigPatch { + path: vec!["providers".into(), "gemini".into(), "enabled".into()], + value: serde_json::json!(true), + }], + ) + .unwrap(); + assert!(out.contains("# Google Gemini through its OpenAI-compatible endpoint.")); + assert!(out.contains("enabled = true # set to true once GEMINI_API_KEY is available")); + let cfg = Config::parse(&out).unwrap(); + assert!(cfg.providers["gemini"].enabled); + } + + #[test] + fn patch_adds_provider_section() { + let out = apply_patches( + EXAMPLE_CONFIG, + &[ConfigPatch { + path: vec!["providers".into(), "vllm".into()], + value: serde_json::json!({"kind": "openai", "base_url": "http://x/v1"}), + }], + ) + .unwrap(); + assert!(out.contains("[providers.vllm]")); + Config::parse(&out).unwrap(); + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..079386f --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,48 @@ +//! CodeCraft – web-based coding harness for AI models. + +mod api; +mod app; +mod config; +mod mcp; +mod permission; +mod prompts; +mod provider; +mod runner; +mod store; +mod tools; +mod types; + +use anyhow::Result; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,codecraft=info"))) + .init(); + + // In Docker, HOME lives on the data volume; make sure it exists for git/npm. + if let Ok(home) = std::env::var("HOME") { + let _ = std::fs::create_dir_all(home); + } + + let path = config::config_path(); + let (cfg, _) = config::load_or_init(&path)?; + tracing::info!("config: {}", path.display()); + tracing::info!("workspace: {}", cfg.workspace().display()); + if cfg.auth_token().is_empty() { + tracing::warn!("auth_token is empty – the API is unprotected. Only use this on localhost."); + } + let bind = cfg.server.bind.clone(); + let app = app::App::new(path, cfg).await?; + let router = api::router(app); + let listener = tokio::net::TcpListener::bind(&bind).await?; + tracing::info!("listening on http://{bind}"); + axum::serve(listener, router).with_graceful_shutdown(shutdown()).await?; + Ok(()) +} + +async fn shutdown() { + let _ = tokio::signal::ctrl_c().await; + tracing::info!("shutting down"); +} diff --git a/server/src/mcp.rs b/server/src/mcp.rs new file mode 100644 index 0000000..03c419c --- /dev/null +++ b/server/src/mcp.rs @@ -0,0 +1,552 @@ +//! Minimal MCP client: JSON-RPC over stdio or Streamable HTTP. +//! +//! Supports tools (list/call, list_changed), roots and elicitation – server +//! questions are forwarded to the user through the same UI as agent questions. + +use crate::app::App; +use crate::config::{McpConfig, McpKind, expand_env}; +use crate::tools::{Tool, ToolCtx, ToolOutput}; +use crate::types::{ImageData, Question, QuestionOption}; +use anyhow::{Context, Result, anyhow, bail}; +use async_trait::async_trait; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use serde::Serialize; +use serde_json::{Map, Value, json}; +use std::collections::{BTreeMap, HashMap}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::{Mutex as AsyncMutex, oneshot}; +use tokio_util::sync::CancellationToken; + +const PROTOCOL_VERSION: &str = "2025-06-18"; +const CALL_TIMEOUT: Duration = Duration::from_secs(600); + +#[derive(Debug, Clone, Serialize)] +pub struct McpToolInfo { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case", tag = "state")] +pub enum McpState { + Disabled, + Connecting, + Connected { tools: usize }, + Failed { error: String }, +} + +type Pending = Arc>>>>; + +enum Transport { + Stdio { + stdin: AsyncMutex, + pending: Pending, + _child: AsyncMutex, + }, + Http { + client: reqwest::Client, + url: String, + headers: BTreeMap, + session: Mutex>, + protocol: Mutex>, + }, +} + +pub struct McpServer { + pub name: String, + transport: Transport, + next_id: AtomicU64, + app: Weak, + /// Session that triggered the current tool call (for elicitation routing). + current_session: Mutex>, + pub tools: Mutex>, +} + +fn rpc(id: u64, method: &str, params: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) +} + +impl McpServer { + pub async fn connect(name: &str, cfg: &McpConfig, app: Weak) -> Result> { + let transport = match cfg.kind { + McpKind::Stdio => { + if cfg.command.is_empty() { + bail!("command is required for stdio servers"); + } + let mut cmd = tokio::process::Command::new(expand_env(&cfg.command)); + cmd.args(cfg.args.iter().map(|a| expand_env(a))) + .envs(cfg.env.iter().map(|(k, v)| (k.clone(), expand_env(v)))) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(app) = app.upgrade() { + cmd.current_dir(app.workspace()); + } + let mut child = cmd.spawn().with_context(|| format!("failed to start {}", cfg.command))?; + let stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + let server_name = name.to_string(); + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(l)) = lines.next_line().await { + tracing::debug!("mcp {server_name} stderr: {l}"); + } + }); + let pending: Pending = Arc::default(); + let server = Arc::new(Self { + name: name.to_string(), + transport: Transport::Stdio { + stdin: AsyncMutex::new(stdin), + pending: pending.clone(), + _child: AsyncMutex::new(child), + }, + next_id: AtomicU64::new(1), + app, + current_session: Mutex::new(None), + tools: Mutex::new(Vec::new()), + }); + let weak = Arc::downgrade(&server); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(msg) = serde_json::from_str::(&line) else { continue }; + let Some(server) = weak.upgrade() else { break }; + // Server requests (elicitation) may wait for the user: never block the reader. + tokio::spawn(async move { server.handle_incoming(msg).await }); + } + // Process ended: fail all pending requests. + for (_, tx) in pending.lock().unwrap().drain() { + let _ = tx.send(Err(anyhow!("MCP server exited"))); + } + }); + server + } + McpKind::Http => Arc::new(Self { + name: name.to_string(), + transport: Transport::Http { + client: crate::provider::http_client(), + url: expand_env(&cfg.url), + headers: cfg.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(), + session: Mutex::new(None), + protocol: Mutex::new(None), + }, + next_id: AtomicU64::new(1), + app, + current_session: Mutex::new(None), + tools: Mutex::new(Vec::new()), + }), + }; + let server = transport; + server.initialize().await?; + server.refresh_tools().await?; + Ok(server) + } + + async fn initialize(&self) -> Result<()> { + let res = self + .request( + "initialize", + json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"elicitation": {}, "roots": {"listChanged": false}}, + "clientInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")} + }), + ) + .await?; + if let Transport::Http { protocol, .. } = &self.transport { + *protocol.lock().unwrap() = + Some(res["protocolVersion"].as_str().unwrap_or(PROTOCOL_VERSION).to_string()); + } + self.notify("notifications/initialized", json!({})).await?; + Ok(()) + } + + pub async fn refresh_tools(&self) -> Result<()> { + let mut tools = Vec::new(); + let mut cursor: Option = None; + loop { + let params = match &cursor { + Some(c) => json!({"cursor": c}), + None => json!({}), + }; + let res = self.request("tools/list", params).await?; + for t in res["tools"].as_array().cloned().unwrap_or_default() { + tools.push(McpToolInfo { + name: t["name"].as_str().unwrap_or_default().to_string(), + description: t["description"].as_str().unwrap_or_default().to_string(), + input_schema: t.get("inputSchema").cloned().unwrap_or(json!({"type": "object"})), + }); + } + cursor = res["nextCursor"].as_str().map(String::from); + if cursor.is_none() { + break; + } + } + *self.tools.lock().unwrap() = tools; + Ok(()) + } + + pub async fn call_tool(&self, session_id: &str, name: &str, args: Value, cancel: CancellationToken) -> Result { + *self.current_session.lock().unwrap() = Some(session_id.to_string()); + let res = tokio::select! { + r = tokio::time::timeout(CALL_TIMEOUT, self.request("tools/call", json!({"name": name, "arguments": args}))) => { + r.map_err(|_| anyhow!("MCP tool call timed out"))? + } + _ = cancel.cancelled() => bail!("aborted"), + }?; + let mut out = ToolOutput { is_error: res["isError"].as_bool().unwrap_or(false), ..Default::default() }; + let mut texts = Vec::new(); + for c in res["content"].as_array().cloned().unwrap_or_default() { + match c["type"].as_str().unwrap_or_default() { + "text" => texts.push(c["text"].as_str().unwrap_or_default().to_string()), + "image" => out.images.push(ImageData { + media_type: c["mimeType"].as_str().unwrap_or("image/png").into(), + data: c["data"].as_str().unwrap_or_default().into(), + }), + "resource" => { + let r = &c["resource"]; + texts.push(match r["text"].as_str() { + Some(t) => format!("[resource {}]\n{t}", r["uri"].as_str().unwrap_or("")), + None => format!("[binary resource {}]", r["uri"].as_str().unwrap_or("")), + }); + } + "resource_link" => texts.push(format!("[resource link {}]", c["uri"].as_str().unwrap_or(""))), + other => texts.push(format!("[{other} content]")), + } + } + if texts.is_empty() { + if let Some(s) = res.get("structuredContent") { + texts.push(serde_json::to_string_pretty(s)?); + } + } + out.content = crate::tools::truncate(&texts.join("\n"), 80_000); + Ok(out) + } + + // --- transport ------------------------------------------------------- + + async fn request(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let msg = rpc(id, method, params); + match &self.transport { + Transport::Stdio { stdin, pending, .. } => { + let (tx, rx) = oneshot::channel(); + pending.lock().unwrap().insert(id, tx); + self.write_stdio(stdin, &msg).await?; + let res = tokio::time::timeout(CALL_TIMEOUT, rx) + .await + .map_err(|_| anyhow!("MCP request {method} timed out"))? + .map_err(|_| anyhow!("MCP server closed the connection"))?; + res + } + Transport::Http { .. } => self.http_send(&msg, Some(id)).await?.ok_or_else(|| anyhow!("no response to {method}")), + } + } + + async fn notify(&self, method: &str, params: Value) -> Result<()> { + let msg = json!({"jsonrpc": "2.0", "method": method, "params": params}); + self.send_raw(&msg).await + } + + async fn send_raw(&self, msg: &Value) -> Result<()> { + match &self.transport { + Transport::Stdio { stdin, .. } => self.write_stdio(stdin, msg).await, + Transport::Http { .. } => self.http_send(msg, None).await.map(|_| ()), + } + } + + async fn write_stdio(&self, stdin: &AsyncMutex, msg: &Value) -> Result<()> { + let mut line = serde_json::to_vec(msg)?; + line.push(b'\n'); + let mut w = stdin.lock().await; + w.write_all(&line).await?; + w.flush().await?; + Ok(()) + } + + /// POST a message. For requests, waits for the matching response, handling + /// server requests (elicitation) that arrive on the same SSE stream. + async fn http_send(&self, msg: &Value, want_id: Option) -> Result> { + let Transport::Http { client, url, headers, session, protocol } = &self.transport else { unreachable!() }; + let mut rb = client + .post(url) + .header("Accept", "application/json, text/event-stream") + .header("Content-Type", "application/json") + .json(msg); + for (k, v) in headers { + rb = rb.header(k, v); + } + if let Some(s) = session.lock().unwrap().clone() { + rb = rb.header("Mcp-Session-Id", s); + } + if let Some(p) = protocol.lock().unwrap().clone() { + rb = rb.header("MCP-Protocol-Version", p); + } + let resp = rb.send().await?; + if let Some(s) = resp.headers().get("mcp-session-id").and_then(|v| v.to_str().ok()) { + *session.lock().unwrap() = Some(s.to_string()); + } + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + bail!("MCP HTTP {status}: {}", body.chars().take(400).collect::()); + } + let Some(want) = want_id else { return Ok(None) }; + let ctype = resp.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + if ctype.contains("text/event-stream") { + let mut events = resp.bytes_stream().eventsource(); + while let Some(ev) = events.next().await { + let ev = ev.map_err(|e| anyhow!("MCP stream error: {e}"))?; + let Ok(v) = serde_json::from_str::(&ev.data) else { continue }; + if v.get("id").and_then(|i| i.as_u64()) == Some(want) && v.get("method").is_none() { + return Ok(Some(Self::result_of(v)?)); + } + // Server→client requests/notifications on the stream. + Box::pin(self.handle_incoming(v)).await; + } + bail!("MCP stream ended without a response"); + } + let v: Value = resp.json().await?; + Ok(Some(Self::result_of(v)?)) + } + + fn result_of(v: Value) -> Result { + if let Some(e) = v.get("error") { + bail!("MCP error {}: {}", e["code"], e["message"].as_str().unwrap_or("")); + } + Ok(v.get("result").cloned().unwrap_or(Value::Null)) + } + + async fn handle_incoming(&self, msg: Value) { + let method = msg.get("method").and_then(|m| m.as_str()).map(String::from); + let id = msg.get("id").cloned(); + match (method, id) { + // Response to one of our requests (stdio). + (None, Some(id)) => { + if let (Transport::Stdio { pending, .. }, Some(id)) = (&self.transport, id.as_u64()) { + if let Some(tx) = pending.lock().unwrap().remove(&id) { + let _ = tx.send(Self::result_of(msg)); + } + } + } + // Server → client request. + (Some(method), Some(id)) => { + let result = match method.as_str() { + "ping" => Ok(json!({})), + "roots/list" => { + let root = self.app.upgrade().map(|a| a.workspace()).unwrap_or_default(); + Ok(json!({"roots": [{"uri": format!("file://{}", root.display()), "name": "workspace"}]})) + } + "elicitation/create" => self.elicit(&msg["params"]).await, + other => Err(anyhow!("method not supported: {other}")), + }; + let reply = match result { + Ok(r) => json!({"jsonrpc": "2.0", "id": id, "result": r}), + Err(e) => json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32601, "message": e.to_string()}}), + }; + if let Err(e) = self.send_raw(&reply).await { + tracing::warn!("mcp {}: failed to reply: {e:#}", self.name); + } + } + (Some(method), None) => { + if method == "notifications/tools/list_changed" { + if let Err(e) = self.refresh_tools().await { + tracing::warn!("mcp {}: tool refresh failed: {e:#}", self.name); + } + } + } + _ => {} + } + } + + /// Turn an elicitation form into UI questions and the answers back into content. + async fn elicit(&self, params: &Value) -> Result { + let app = self.app.upgrade().ok_or_else(|| anyhow!("app gone"))?; + let session = self + .current_session + .lock() + .unwrap() + .clone() + .ok_or_else(|| anyhow!("no active session for elicitation"))?; + let message = params["message"].as_str().unwrap_or("").to_string(); + let props = params["requestedSchema"]["properties"].as_object().cloned().unwrap_or_default(); + let mut questions = Vec::new(); + let mut keys = Vec::new(); + for (key, schema) in &props { + let title = schema["title"].as_str().unwrap_or(key); + let desc = schema["description"].as_str().unwrap_or(""); + let mut options: Vec = Vec::new(); + if let Some(e) = schema["enum"].as_array() { + let names = schema["enumNames"].as_array(); + for (i, v) in e.iter().enumerate() { + let label = v.as_str().map(String::from).unwrap_or_else(|| v.to_string()); + let description = names.and_then(|n| n.get(i)).and_then(|n| n.as_str()).unwrap_or("").to_string(); + options.push(QuestionOption { label, description }); + } + } else if schema["type"] == "boolean" { + options = vec![ + QuestionOption { label: "true".into(), description: "Yes".into() }, + QuestionOption { label: "false".into(), description: "No".into() }, + ]; + } + let question = if props.len() == 1 && !message.is_empty() { + format!("{message}\n\n{title}{}", if desc.is_empty() { String::new() } else { format!(" – {desc}") }) + } else { + format!("{title}{}", if desc.is_empty() { String::new() } else { format!(" – {desc}") }) + }; + questions.push(Question { question, header: key.chars().take(12).collect(), options, multi_select: false }); + keys.push((key.clone(), schema.clone())); + } + if questions.is_empty() { + questions.push(Question { + question: message.clone(), + header: "Confirm".into(), + options: vec![ + QuestionOption { label: "accept".into(), description: String::new() }, + QuestionOption { label: "decline".into(), description: String::new() }, + ], + multi_select: false, + }); + } + let source = format!("mcp:{}", self.name); + let answers = app.ask_user(&session, &source, questions.clone(), CancellationToken::new()).await?; + let Some(answers) = answers else { return Ok(json!({"action": "cancel"})) }; + if keys.is_empty() { + let a = answers.get(&questions[0].question).and_then(|v| v.as_str()).unwrap_or("decline"); + return Ok(json!({"action": if a == "accept" { "accept" } else { "decline" }})); + } + let mut content = Map::new(); + for ((key, schema), q) in keys.iter().zip(&questions) { + let Some(raw) = answers.get(&q.question).and_then(|v| v.as_str()) else { continue }; + let v = match schema["type"].as_str() { + Some("boolean") => json!(raw == "true" || raw.eq_ignore_ascii_case("yes")), + Some("integer") => raw.trim().parse::().map(|n| json!(n)).unwrap_or(json!(raw)), + Some("number") => raw.trim().parse::().map(|n| json!(n)).unwrap_or(json!(raw)), + _ => json!(raw), + }; + content.insert(key.clone(), v); + } + Ok(json!({"action": "accept", "content": content})) + } +} + +// --------------------------------------------------------------------------- + +/// All configured MCP servers with their connection state. +#[derive(Default)] +pub struct McpManager { + servers: Mutex>>, + states: Mutex>, +} + +impl McpManager { + pub fn status_json(&self) -> Value { + let states = self.states.lock().unwrap(); + let servers = self.servers.lock().unwrap(); + let mut out = Map::new(); + for (name, st) in states.iter() { + let tools: Vec = servers + .get(name) + .map(|s| s.tools.lock().unwrap().iter().map(|t| json!({"name": t.name, "description": t.description})).collect()) + .unwrap_or_default(); + out.insert(name.clone(), json!({"status": st, "tools": tools})); + } + Value::Object(out) + } + + /// (Re)connect all servers from config. Runs connections concurrently. + pub async fn start_all(self: &Arc, app: Weak, configs: BTreeMap) { + self.servers.lock().unwrap().clear(); + self.states.lock().unwrap().clear(); + let mut tasks = Vec::new(); + for (name, cfg) in configs { + if !cfg.enabled { + self.states.lock().unwrap().insert(name, McpState::Disabled); + continue; + } + self.states.lock().unwrap().insert(name.clone(), McpState::Connecting); + let me = self.clone(); + let app = app.clone(); + tasks.push(tokio::spawn(async move { + let res = tokio::time::timeout(Duration::from_secs(60), McpServer::connect(&name, &cfg, app.clone())).await; + let state = match res { + Ok(Ok(server)) => { + let n = server.tools.lock().unwrap().len(); + me.servers.lock().unwrap().insert(name.clone(), server); + McpState::Connected { tools: n } + } + Ok(Err(e)) => McpState::Failed { error: format!("{e:#}") }, + Err(_) => McpState::Failed { error: "connection timed out".into() }, + }; + tracing::info!("mcp {name}: {state:?}"); + me.states.lock().unwrap().insert(name, state); + if let Some(app) = app.upgrade() { + app.emit(crate::types::Event::McpStatus { servers: me.status_json() }); + } + })); + } + for t in tasks { + let _ = t.await; + } + } + + pub fn tools(&self) -> Vec> { + let servers = self.servers.lock().unwrap(); + let mut out: Vec> = Vec::new(); + for (sname, server) in servers.iter() { + for t in server.tools.lock().unwrap().iter() { + out.push(Arc::new(McpTool { + full_name: tool_name(sname, &t.name), + server: server.clone(), + info: t.clone(), + })); + } + } + out + } +} + +/// Tool names must match ^[a-zA-Z0-9_-]{1,64}$ for most providers. +pub fn tool_name(server: &str, tool: &str) -> String { + let clean = |s: &str| s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect::(); + let mut n = format!("mcp__{}__{}", clean(server), clean(tool)); + n.truncate(64); + n +} + +pub struct McpTool { + full_name: String, + server: Arc, + info: McpToolInfo, +} + +#[async_trait] +impl Tool for McpTool { + fn name(&self) -> &str { + &self.full_name + } + fn description(&self) -> String { + format!("[MCP {}] {}", self.server.name, self.info.description) + } + fn schema(&self) -> Value { + let mut s = self.info.input_schema.clone(); + if s.get("type").is_none() { + s["type"] = json!("object"); + } + s + } + fn title(&self, _input: &Value) -> String { + format!("{} → {}", self.server.name, self.info.name) + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + self.server.call_tool(&ctx.session_id, &self.info.name, input, ctx.cancel.clone()).await + } +} diff --git a/server/src/permission.rs b/server/src/permission.rs new file mode 100644 index 0000000..82172d6 --- /dev/null +++ b/server/src/permission.rs @@ -0,0 +1,218 @@ +//! Permission evaluation: config rules, session permission modes and +//! "always allow" approvals given in the UI. + +use crate::config::PermissionConfig; +use crate::tools::Tool; +use serde_json::Value; +use std::collections::HashSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + Allow, + Ask, + Deny, +} + +fn parse(s: &str) -> Option { + match s { + "allow" => Some(Decision::Allow), + "ask" => Some(Decision::Ask), + "deny" => Some(Decision::Deny), + _ => None, + } +} + +fn glob_match(pattern: &str, text: &str) -> bool { + globset::GlobBuilder::new(pattern) + .literal_separator(false) + .build() + .map(|g| g.compile_matcher().is_match(text)) + .unwrap_or(false) +} + +/// Split a shell command into simple commands on && || ; | and newlines. +/// Returns None when the command contains constructs we cannot judge safely +/// (command substitution, backticks). +pub fn split_command(cmd: &str) -> Option> { + if cmd.contains("$(") || cmd.contains('`') || cmd.contains("<(") || cmd.contains(">(") { + return None; + } + let mut parts = Vec::new(); + let mut cur = String::new(); + let mut chars = cmd.chars().peekable(); + let (mut sq, mut dq) = (false, false); + while let Some(c) = chars.next() { + match c { + '\'' if !dq => { + sq = !sq; + cur.push(c) + } + '"' if !sq => { + dq = !dq; + cur.push(c) + } + '\\' if !sq => { + cur.push(c); + if let Some(n) = chars.next() { + cur.push(n); + } + } + ';' | '\n' | '|' | '&' if !sq && !dq => { + // "&" alone (background) and "&&", "||", "|", ";" all separate commands. + if (c == '|' || c == '&') && chars.peek() == Some(&c) { + chars.next(); + } + // Redirections like 2>&1 are not separators. + if c == '&' && cur.ends_with('>') { + cur.push(c); + continue; + } + parts.push(std::mem::take(&mut cur)); + } + _ => cur.push(c), + } + } + parts.push(cur); + Some(parts.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect()) +} + +/// Prefix stored for "always allow" on a bash command: the first two words. +pub fn bash_prefix(cmd: &str) -> String { + cmd.split_whitespace().take(2).collect::>().join(" ") +} + +pub struct Evaluator<'a> { + pub cfg: &'a PermissionConfig, + pub mode: &'a str, + /// Session approvals: tool names, or "bash:". + pub approved: &'a HashSet, +} + +impl Evaluator<'_> { + pub fn evaluate(&self, tool: &dyn Tool, input: &Value) -> Decision { + let name = tool.name(); + let base = self.tool_rule(name).unwrap_or(if tool.read_only() { Decision::Allow } else { Decision::Ask }); + + if name == "bash" { + let cmd = input["command"].as_str().unwrap_or(""); + let bash = self.bash_decision(cmd); + if bash == Some(Decision::Deny) || base == Decision::Deny { + return Decision::Deny; + } + if self.mode == "bypass" { + return Decision::Allow; + } + return bash.unwrap_or(base); + } + if base == Decision::Deny { + return Decision::Deny; + } + if self.mode == "bypass" || self.approved.contains(name) { + return Decision::Allow; + } + if self.mode == "accept_edits" && tool.is_edit() { + return Decision::Allow; + } + base + } + + fn tool_rule(&self, name: &str) -> Option { + if let Some(d) = self.cfg.tools.get(name).and_then(|s| parse(s)) { + return Some(d); + } + // Glob rules, most specific (longest) first. + let mut globs: Vec<(&String, &String)> = + self.cfg.tools.iter().filter(|(k, _)| k.contains('*')).collect(); + globs.sort_by_key(|(k, _)| std::cmp::Reverse(k.len())); + globs.into_iter().find(|(k, _)| glob_match(k, name)).and_then(|(_, v)| parse(v)) + } + + /// Some(Allow) only if every simple command is allowed; Some(Deny) if any is denied. + fn bash_decision(&self, cmd: &str) -> Option { + // Deny rules are checked against the full command as well as each part. + let deny_hit = |text: &str| { + self.cfg.bash_rules.iter().any(|(p, d)| d == "deny" && glob_match(p, text)) + }; + if deny_hit(cmd.trim()) { + return Some(Decision::Deny); + } + let parts = split_command(cmd)?; + if parts.iter().any(|p| deny_hit(p)) { + return Some(Decision::Deny); + } + let all_allowed = !parts.is_empty() + && parts.iter().all(|p| { + self.approved.contains(&format!("bash:{}", bash_prefix(p))) + || self.cfg.bash_rules.iter().any(|(pat, d)| d == "allow" && glob_match(pat, p)) + }); + if all_allowed { Some(Decision::Allow) } else { None } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + struct Fake(&'static str, bool); + #[async_trait::async_trait] + impl Tool for Fake { + fn name(&self) -> &str { + self.0 + } + fn description(&self) -> String { + String::new() + } + fn schema(&self) -> Value { + Value::Null + } + fn read_only(&self) -> bool { + self.1 + } + async fn run(&self, _: Value, _: &crate::tools::ToolCtx) -> anyhow::Result { + unreachable!() + } + } + + fn cfg() -> PermissionConfig { + let mut bash_rules = BTreeMap::new(); + bash_rules.insert("git status*".into(), "allow".into()); + bash_rules.insert("ls*".into(), "allow".into()); + bash_rules.insert("rm -rf /*".into(), "deny".into()); + let mut tools = BTreeMap::new(); + tools.insert("bash".into(), "ask".into()); + tools.insert("mcp__*".into(), "ask".into()); + tools.insert("mcp__trilium__*".into(), "allow".into()); + PermissionConfig { bash_rules, tools } + } + + #[test] + fn compound_commands_need_every_part_allowed() { + let c = cfg(); + let approved = HashSet::new(); + let e = Evaluator { cfg: &c, mode: "default", approved: &approved }; + let bash = Fake("bash", false); + let cmd = |s: &str| serde_json::json!({"command": s}); + assert_eq!(e.evaluate(&bash, &cmd("git status")), Decision::Allow); + assert_eq!(e.evaluate(&bash, &cmd("git status && ls -la")), Decision::Allow); + assert_eq!(e.evaluate(&bash, &cmd("git status; rm -rf build")), Decision::Ask); + assert_eq!(e.evaluate(&bash, &cmd("ls $(rm -rf x)")), Decision::Ask); + assert_eq!(e.evaluate(&bash, &cmd("git status 2>&1 | ls")), Decision::Allow); + assert_eq!(e.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny); + } + + #[test] + fn modes_and_globs() { + let c = cfg(); + let approved: HashSet = ["bash:npm test".to_string()].into(); + let bypass = Evaluator { cfg: &c, mode: "bypass", approved: &approved }; + let def = Evaluator { cfg: &c, mode: "default", approved: &approved }; + let bash = Fake("bash", false); + assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "make"})), Decision::Allow); + assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "rm -rf /"})), Decision::Deny); + assert_eq!(def.evaluate(&bash, &serde_json::json!({"command": "npm test -- --watch"})), Decision::Allow); + assert_eq!(def.evaluate(&Fake("mcp__trilium__search", false), &Value::Null), Decision::Allow); + assert_eq!(def.evaluate(&Fake("mcp__other__x", false), &Value::Null), Decision::Ask); + assert_eq!(def.evaluate(&Fake("read", true), &Value::Null), Decision::Allow); + } +} diff --git a/server/src/prompts.rs b/server/src/prompts.rs new file mode 100644 index 0000000..e889911 --- /dev/null +++ b/server/src/prompts.rs @@ -0,0 +1,334 @@ +//! Agents (modes), system prompts, project instructions, slash commands and skills. +//! +//! Compatible with Claude Code / opencode layouts: AGENTS.md, CLAUDE.md, +//! .claude/{agents,commands,skills}, .codecraft/{agents,commands,skills}. + +use crate::app::App; +use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str}; +use anyhow::Result; +use async_trait::async_trait; +use serde::Serialize; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize)] +pub struct Agent { + pub name: String, + pub description: String, + /// "primary" (selectable mode) or "subagent" (task tool only). + pub mode: String, + #[serde(skip)] + pub prompt: String, + /// Allowed tools; None = all. + pub tools: Option>, + pub read_only: bool, + pub model: Option, +} + +const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools. + +# Working style +- Be direct and concise. Lead with what you did or found; no filler. +- Understand before changing: read relevant files, search the codebase, follow existing conventions, libraries and patterns. +- Make the change completely: implement, then verify (build, run tests, lint) when possible. Report failures honestly with the relevant output. +- Prefer editing existing files over creating new ones. Never create documentation files unless asked. +- Use todo_write to plan and track multi-step work; keep exactly one item in progress. +- Use the dedicated tools (read, edit, write, glob, grep, list) instead of shell equivalents. Batch independent tool calls in one response. +- Use the task tool for broad searches or independent sub-tasks to keep your own context focused. +- If you are blocked on a decision that is genuinely the user's, use ask_user. Otherwise pick a sensible default and mention it. +- Never commit, push or perform destructive/irreversible actions unless the user asked for it. +- Reference code as path:line. +- Answer in the language the user writes in."#; + +const BUILD_PROMPT: &str = "Mode: BUILD. You have full access to read, modify and run things in the project."; + +const PLAN_PROMPT: &str = r#"Mode: PLAN. You are in read-only planning mode: you must not modify files, run state-changing commands or make commits. +Investigate the codebase, ask clarifying questions with ask_user where requirements are ambiguous, and finish with a concrete, step-by-step implementation plan (files to change, approach, risks, verification). The user switches to build mode to execute it."#; + +const CRAFT_PROMPT: &str = r#"Mode: CRAFT. You recreate the look and behaviour of a target (website, app or game) as a NEW, original project the user owns, working in four phases. Keep the user informed of the current phase with todo_write (one item per phase). + +1. STUDY – Inspect the target. For websites use browser_snapshot on each relevant page (it captures every configured screen width plus visible text). Note layout, colours, typography, spacing, components, navigation and behaviour. For apps/games use screenshots the user provides and their description. Summarise your findings briefly. +2. BUILD – Create the new project in the working directory, in the stack the user asked for (otherwise a sensible default: plain HTML/CSS/JS for sites). Write clean, original code and original placeholder content/assets; do not copy proprietary text, logos, images or code. +3. CHECK – Serve or open the build (e.g. `python3 -m http.server PORT` in the background, or file://), snapshot it with browser_snapshot using a 'build-*' label, and compare each width against the original with image_compare. List what does not match yet and the similarity per page/width. +4. READY – Refine until the result is close (or improvements stall), then tell the user how to open/run it and summarise remaining differences. Ask for changes. + +Rules: ask the user (ask_user) when a decision is theirs (stack, scope, which pages). {CLEAN_ROOM}"#; + +const CLEAN_ROOM_ON: &str = "Clean room is ON: only use what a visitor can see (screenshots, visible text, measured layout). Do not read the target's page source, scripts or files (web_fetch with raw=true is refused)."; +const CLEAN_ROOM_OFF: &str = "Deep mode is ON: you may also read the target's page source and scripts for the user's private use."; + +const GENERAL_PROMPT: &str = "You are a subagent. Complete the delegated task autonomously and end with a concise, complete report of what you found or did – it is the only thing the calling agent sees."; +const EXPLORE_PROMPT: &str = "You are a fast, read-only exploration subagent. Search broadly (glob, grep, read excerpts) and report the conclusion with precise file:line references. Do not modify anything."; + +pub fn builtin_agents(clean_room: bool) -> Vec { + let craft = CRAFT_PROMPT.replace("{CLEAN_ROOM}", if clean_room { CLEAN_ROOM_ON } else { CLEAN_ROOM_OFF }); + vec![ + Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, model: None }, + Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, model: None }, + Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, model: None }, + Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, model: None }, + Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, model: None }, + ] +} + +/// Parse "---\nkey: value\n---\nbody" frontmatter (flat keys only). +pub fn frontmatter(text: &str) -> (BTreeMap, String) { + let mut meta = BTreeMap::new(); + let t = text.trim_start_matches('\u{feff}'); + if let Some(rest) = t.strip_prefix("---") { + if let Some(end) = rest.find("\n---") { + for line in rest[..end].lines() { + if let Some((k, v)) = line.split_once(':') { + meta.insert(k.trim().to_string(), v.trim().trim_matches('"').trim_matches('\'').to_string()); + } + } + let body = rest[end + 4..].trim_start_matches(['-', '\n', '\r']).to_string(); + return (meta, body); + } + } + (meta, t.to_string()) +} + +/// Resource directories, lowest priority first: data dir, workspace, project. +fn resource_dirs(app: &App, cwd: &Path, kind: &str) -> Vec { + let mut dirs = vec![app.data_dir().join(kind)]; + let ws = app.workspace(); + let mut chain = vec![ws.clone()]; + if cwd != ws { + chain.push(cwd.to_path_buf()); + } + for base in chain { + dirs.push(base.join(".claude").join(kind)); + dirs.push(base.join(".codecraft").join(kind)); + } + dirs +} + +pub fn agents(app: &App, cwd: &Path) -> Vec { + let cfg = app.config(); + let mut map: BTreeMap = + builtin_agents(cfg.craft.clean_room).into_iter().map(|a| (a.name.clone(), a)).collect(); + // Markdown agents (Claude Code format). + for dir in resource_dirs(app, cwd, "agents") { + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for e in entries.flatten() { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) != Some("md") { + continue; + } + let Ok(text) = std::fs::read_to_string(&p) else { continue }; + let (meta, body) = frontmatter(&text); + let name = meta.get("name").cloned().unwrap_or_else(|| p.file_stem().unwrap().to_string_lossy().into()); + let tools = meta.get("tools").map(|t| t.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect()); + map.insert(name.clone(), Agent { + name, + description: meta.get("description").cloned().unwrap_or_default(), + mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()), + prompt: body, + tools, + read_only: false, + model: meta.get("model").filter(|m| m.contains('/')).cloned(), + }); + } + } + // Config agents override everything. + for (name, a) in &cfg.agents { + let base = map.get(name).cloned(); + map.insert(name.clone(), Agent { + name: name.clone(), + description: a.description.clone().or(base.as_ref().map(|b| b.description.clone())).unwrap_or_default(), + mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()), + prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(), + tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())), + read_only: base.as_ref().is_some_and(|b| b.read_only), + model: a.model.clone().or(base.and_then(|b| b.model)), + }); + } + map.into_values().collect() +} + +pub fn find_agent(app: &App, cwd: &Path, name: &str) -> Agent { + agents(app, cwd) + .into_iter() + .find(|a| a.name == name) + .unwrap_or_else(|| builtin_agents(true).remove(0)) +} + +/// AGENTS.md / CLAUDE.md files from the data dir, workspace root down to cwd. +fn instruction_files(app: &App, cwd: &Path) -> Vec<(PathBuf, String)> { + let mut dirs = vec![app.data_dir()]; + let ws = app.workspace(); + let mut d = cwd.to_path_buf(); + let mut chain = Vec::new(); + loop { + chain.push(d.clone()); + if d == ws || !d.starts_with(&ws) || !d.pop() { + break; + } + } + chain.reverse(); + dirs.extend(chain); + let mut out = Vec::new(); + for dir in dirs { + for name in ["AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"] { + let p = dir.join(name); + if let Ok(text) = std::fs::read_to_string(&p) { + if !text.trim().is_empty() { + out.push((p, text)); + } + } + } + } + out +} + +pub fn system_prompt(app: &App, agent: &Agent, cwd: &Path, model: &str) -> String { + let mut s = String::from(BASE_PROMPT); + s.push_str("\n\n"); + s.push_str(&agent.prompt); + let git = cwd.join(".git").exists(); + s.push_str(&format!( + "\n\n# Environment\n- Working directory: {}\n- Workspace root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n", + cwd.display(), + app.workspace().display(), + if git { "yes" } else { "no" }, + std::env::consts::OS, + std::env::consts::ARCH, + chrono::Local::now().format("%Y-%m-%d"), + )); + let skills = skills(app, cwd); + if !skills.is_empty() { + s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n"); + for sk in &skills { + s.push_str(&format!("- {}: {}\n", sk.name, sk.description)); + } + } + let files = instruction_files(app, cwd); + if !files.is_empty() { + s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n"); + for (p, text) in files { + s.push_str(&format!("\n\n{}\n\n", p.display(), text.trim())); + } + } + s +} + +// --------------------------------------------------------------------------- +// Slash commands + +#[derive(Debug, Clone, Serialize)] +pub struct Command { + pub name: String, + pub description: String, + #[serde(skip)] + pub body: String, + pub agent: Option, +} + +pub fn commands(app: &App, cwd: &Path) -> Vec { + let mut map = BTreeMap::new(); + for dir in resource_dirs(app, cwd, "commands") { + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for e in entries.flatten() { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) != Some("md") { + continue; + } + let Ok(text) = std::fs::read_to_string(&p) else { continue }; + let (meta, body) = frontmatter(&text); + let name = p.file_stem().unwrap().to_string_lossy().to_string(); + let description = meta + .get("description") + .cloned() + .unwrap_or_else(|| body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(80).collect()); + map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned() }); + } + } + map.into_values().collect() +} + +/// Expand "/name args" into the command's prompt. Returns None if not a known command. +pub fn expand_command(app: &App, cwd: &Path, text: &str) -> Option<(String, Option)> { + let rest = text.strip_prefix('/')?; + let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + let cmd = commands(app, cwd).into_iter().find(|c| c.name == name)?; + let args = args.trim(); + let mut body = cmd.body.replace("$ARGUMENTS", args); + for (i, a) in args.split_whitespace().enumerate() { + body = body.replace(&format!("${}", i + 1), a); + } + if !cmd.body.contains("$ARGUMENTS") && !args.is_empty() { + body.push_str(&format!("\n\n{args}")); + } + Some((body, cmd.agent)) +} + +// --------------------------------------------------------------------------- +// Skills + +#[derive(Debug, Clone, Serialize)] +pub struct Skill { + pub name: String, + pub description: String, + pub path: PathBuf, +} + +pub fn skills(app: &App, cwd: &Path) -> Vec { + let mut map = BTreeMap::new(); + for dir in resource_dirs(app, cwd, "skills") { + let Ok(entries) = std::fs::read_dir(&dir) else { continue }; + for e in entries.flatten() { + let p = e.path().join("SKILL.md"); + let Ok(text) = std::fs::read_to_string(&p) else { continue }; + let (meta, _) = frontmatter(&text); + let name = meta.get("name").cloned().unwrap_or_else(|| e.file_name().to_string_lossy().into()); + map.insert(name.clone(), Skill { name, description: meta.get("description").cloned().unwrap_or_default(), path: p }); + } + } + map.into_values().collect() +} + +pub struct SkillTool; + +#[async_trait] +impl Tool for SkillTool { + fn name(&self) -> &str { + "skill" + } + fn description(&self) -> String { + "Load a skill (packaged instructions for a kind of task) by name. Follow the returned instructions.".into() + } + fn schema(&self) -> Value { + json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}) + } + fn read_only(&self) -> bool { + true + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let name = arg_str(&input, "name")?; + let Some(sk) = skills(&ctx.app, &ctx.cwd).into_iter().find(|s| s.name == name) else { + return Ok(ToolOutput::error(format!("unknown skill '{name}'"))); + }; + let text = std::fs::read_to_string(&sk.path)?; + let (_, body) = frontmatter(&text); + let dir = sk.path.parent().unwrap_or(Path::new(".")); + Ok(ToolOutput::text(format!("Skill '{name}' (base directory: {}):\n\n{body}", dir.display()))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_frontmatter() { + let (m, b) = frontmatter("---\nname: x\ndescription: \"does y\"\n---\nBody here\n"); + assert_eq!(m["name"], "x"); + assert_eq!(m["description"], "does y"); + assert_eq!(b.trim(), "Body here"); + let (m2, b2) = frontmatter("no frontmatter"); + assert!(m2.is_empty()); + assert_eq!(b2, "no frontmatter"); + } +} diff --git a/server/src/provider/anthropic.rs b/server/src/provider/anthropic.rs new file mode 100644 index 0000000..2f396d3 --- /dev/null +++ b/server/src/provider/anthropic.rs @@ -0,0 +1,301 @@ +//! Anthropic Messages API (streaming). Also works with Anthropic-compatible +//! third-party endpoints; first-party-only features are gated on the host. + +use super::{ChatRequest, Endpoint, Provider, StreamEvent, http_error}; +use crate::config::ModelConfig; +use crate::types::{Message, Part, Role, Usage}; +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +const API_VERSION: &str = "2026-10-21"; + +pub struct Anthropic { + client: reqwest::Client, + ep: Endpoint, +} + +impl Anthropic { + pub fn new(client: reqwest::Client, ep: Endpoint) -> Self { + Self { client, ep } + } + + fn first_party(&self) -> bool { + self.ep.base_url.contains("api.anthropic.com") + } + + fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder { + let mut rb = self.client.request(method, format!("{}{}", self.ep.base_url, path)); + if !self.ep.headers.keys().any(|k| k.eq_ignore_ascii_case("anthropic-version")) { + rb = rb.header("anthropic-version", API_VERSION); + } + self.ep.apply(rb) + } +} + +/// Convert our neutral history into Anthropic message params. +pub fn to_wire_messages(messages: &[Message]) -> Vec { + let mut out: Vec = Vec::new(); + for m in messages { + let mut content = Vec::new(); + for p in &m.parts { + match p { + Part::Text { text } if !text.is_empty() => { + content.push(json!({"type": "text", "text": text})) + } + Part::Summary { text } => content.push(json!({ + "type": "text", + "text": format!("\n{text}\n") + })), + Part::Image { image } => content.push(json!({ + "type": "image", + "source": {"type": "base64", "media_type": image.media_type, "data": image.data} + })), + // Thinking blocks are replayed unchanged (assistant turns only). + Part::Reasoning { text, signature } if m.role == Role::Assistant => { + match signature.as_deref() { + Some(sig) if sig.starts_with("redacted:") => content.push(json!({ + "type": "redacted_thinking", + "data": &sig["redacted:".len()..] + })), + Some(sig) => content.push(json!({ + "type": "thinking", "thinking": text, "signature": sig + })), + // Reasoning from another provider has no signature – drop it. + None => {} + } + } + Part::ToolCall { id, name, input, .. } => content.push(json!({ + "type": "tool_use", "id": id, "name": name, "input": input + })), + Part::ToolResult { call_id, content: text, images, is_error, .. } => { + let mut blocks = vec![json!({"type": "text", "text": if text.is_empty() { "(no output)" } else { text }})]; + for img in images { + blocks.push(json!({ + "type": "image", + "source": {"type": "base64", "media_type": img.media_type, "data": img.data} + })); + } + content.push(json!({ + "type": "tool_result", "tool_use_id": call_id, + "content": blocks, "is_error": is_error + })) + } + _ => {} + } + } + if content.is_empty() { + continue; + } + let role = match m.role { + Role::User => "user", + Role::Assistant => "assistant", + }; + // Merge consecutive messages of the same role (the API requires alternation). + if let Some(last) = out.last_mut() { + if last["role"] == role { + last["content"].as_array_mut().unwrap().extend(content); + continue; + } + } + out.push(json!({"role": role, "content": content})); + } + // tool_result blocks must come first in a user message. + for msg in out.iter_mut().filter(|m| m["role"] == "user") { + let arr = msg["content"].as_array_mut().unwrap(); + arr.sort_by_key(|b| if b["type"] == "tool_result" { 0 } else { 1 }); + } + out +} + +#[derive(Default)] +struct BlockState { + kind: String, + id: String, + name: String, + json: String, + signature: String, +} + +#[async_trait] +impl Provider for Anthropic { + async fn stream( + &self, + req: ChatRequest, + tx: mpsc::Sender, + cancel: CancellationToken, + ) -> Result<()> { + let first_party = self.first_party(); + let tools: Vec = req + .tools + .iter() + .map(|t| { + let mut v = json!({ + "name": t.name, "description": t.description, "input_schema": t.input_schema + }); + if first_party { + v["eager_input_streaming"] = json!(true); + } + v + }) + .collect(); + let mut body = json!({ + "model": req.model.id, + "max_tokens": req.max_tokens, + "stream": true, + "system": [{"type": "text", "text": req.system, "cache_control": {"type": "ephemeral"}}], + "messages": to_wire_messages(&req.messages), + }); + if !tools.is_empty() { + body["tools"] = Value::Array(tools); + } + if first_party { + // Automatic breakpoint on the last cacheable block (conversation prefix). + body["cache_control"] = json!({"type": "ephemeral"}); + } + if req.reasoning && req.model.reasoning { + body["thinking"] = json!({"type": "adaptive", "display": "summarized"}); + } + if let Some(effort) = &req.model.effort { + body["output_config"] = json!({"effort": effort}); + } + + let resp = self.request(reqwest::Method::POST, "/messages").json(&body).send().await?; + if !resp.status().is_success() { + return Err(http_error(resp).await); + } + + let mut events = resp.bytes_stream().eventsource(); + let mut blocks: BTreeMap = BTreeMap::new(); + let mut usage = Usage::default(); + let mut stop_reason = String::from("end_turn"); + + loop { + let ev = tokio::select! { + _ = cancel.cancelled() => return Ok(()), + ev = events.next() => ev, + }; + let Some(ev) = ev else { break }; + let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?; + if ev.data.is_empty() { + continue; + } + let data: Value = serde_json::from_str(&ev.data)?; + match data["type"].as_str().unwrap_or_default() { + "message_start" => { + let u = &data["message"]["usage"]; + usage.input_tokens = u["input_tokens"].as_u64().unwrap_or(0); + usage.cache_read_tokens = u["cache_read_input_tokens"].as_u64().unwrap_or(0); + usage.cache_write_tokens = u["cache_creation_input_tokens"].as_u64().unwrap_or(0); + } + "content_block_start" => { + let idx = data["index"].as_u64().unwrap_or(0); + let cb = &data["content_block"]; + let mut st = BlockState { + kind: cb["type"].as_str().unwrap_or_default().to_string(), + ..Default::default() + }; + match st.kind.as_str() { + "tool_use" => { + st.id = cb["id"].as_str().unwrap_or_default().into(); + st.name = cb["name"].as_str().unwrap_or_default().into(); + } + "redacted_thinking" => { + st.signature = format!("redacted:{}", cb["data"].as_str().unwrap_or_default()); + } + "text" => { + if let Some(t) = cb["text"].as_str().filter(|t| !t.is_empty()) { + tx.send(StreamEvent::TextDelta(t.into())).await.ok(); + } + } + _ => {} + } + blocks.insert(idx, st); + } + "content_block_delta" => { + let idx = data["index"].as_u64().unwrap_or(0); + let d = &data["delta"]; + let st = blocks.entry(idx).or_default(); + match d["type"].as_str().unwrap_or_default() { + "text_delta" => { + tx.send(StreamEvent::TextDelta(d["text"].as_str().unwrap_or_default().into())).await.ok(); + } + "thinking_delta" => { + tx.send(StreamEvent::ReasoningDelta(d["thinking"].as_str().unwrap_or_default().into())).await.ok(); + } + "signature_delta" => st.signature.push_str(d["signature"].as_str().unwrap_or_default()), + "input_json_delta" => st.json.push_str(d["partial_json"].as_str().unwrap_or_default()), + _ => {} + } + } + "content_block_stop" => { + let idx = data["index"].as_u64().unwrap_or(0); + if let Some(st) = blocks.remove(&idx) { + match st.kind.as_str() { + "tool_use" => { + let input = if st.json.trim().is_empty() { + json!({}) + } else { + // Invalid JSON (e.g. truncated) is passed on as a marker; the + // agent loop answers it with an error tool_result. + serde_json::from_str(&st.json) + .unwrap_or_else(|_| json!({"__invalid_json": st.json})) + }; + tx.send(StreamEvent::ToolCall { id: st.id, name: st.name, input, extra: None }).await.ok(); + } + "thinking" | "redacted_thinking" => { + tx.send(StreamEvent::ReasoningSignature(st.signature)).await.ok(); + } + _ => {} + } + } + } + "message_delta" => { + if let Some(r) = data["delta"]["stop_reason"].as_str() { + stop_reason = r.to_string(); + } + if let Some(o) = data["usage"]["output_tokens"].as_u64() { + usage.output_tokens = o; + } + } + "error" => { + let msg = data["error"]["message"].as_str().unwrap_or("unknown error"); + return Err(anyhow!("provider error: {msg}")); + } + _ => {} + } + } + tx.send(StreamEvent::Usage(usage)).await.ok(); + tx.send(StreamEvent::Done { stop_reason }).await.ok(); + Ok(()) + } + + async fn list_models(&self) -> Result> { + let resp = self.request(reqwest::Method::GET, "/models?limit=100").send().await?; + if !resp.status().is_success() { + return Err(http_error(resp).await); + } + let v: Value = resp.json().await?; + Ok(v["data"] + .as_array() + .map(|a| { + a.iter() + .map(|m| ModelConfig { + id: m["id"].as_str().unwrap_or_default().into(), + name: m["display_name"].as_str().map(String::from), + context: m["max_input_tokens"].as_u64(), + max_output: m["max_tokens"].as_u64(), + reasoning: true, + vision: true, + ..Default::default() + }) + .collect() + }) + .unwrap_or_default()) + } +} diff --git a/server/src/provider/mod.rs b/server/src/provider/mod.rs new file mode 100644 index 0000000..1d31472 --- /dev/null +++ b/server/src/provider/mod.rs @@ -0,0 +1,209 @@ +//! Model providers. Two wire protocols cover all targets: +//! Anthropic Messages and OpenAI-compatible chat completions. + +pub mod anthropic; +pub mod openai; + +use crate::config::{Config, ModelConfig, ProviderConfig, ProviderKind, expand_env}; +use crate::types::{Message, Usage}; +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, Clone, Serialize)] +pub struct ToolDef { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +#[derive(Debug, Clone)] +pub struct ChatRequest { + pub model: ModelConfig, + pub system: String, + pub messages: Vec, + pub tools: Vec, + pub max_tokens: u64, + /// Enable extended thinking / reasoning when the model supports it. + pub reasoning: bool, +} + +#[derive(Debug, Clone)] +pub enum StreamEvent { + TextDelta(String), + ReasoningDelta(String), + ReasoningSignature(String), + ToolCall { id: String, name: String, input: Value, extra: Option }, + Usage(Usage), + Done { stop_reason: String }, +} + +#[async_trait] +pub trait Provider: Send + Sync { + async fn stream( + &self, + req: ChatRequest, + tx: mpsc::Sender, + cancel: CancellationToken, + ) -> Result<()>; + + async fn list_models(&self) -> Result>; +} + +/// Connection settings shared by both protocol implementations. +#[derive(Debug, Clone)] +pub struct Endpoint { + pub base_url: String, + pub api_key: String, + pub auth_header: String, + pub auth_prefix: String, + pub headers: BTreeMap, +} + +impl Endpoint { + fn from_config(p: &ProviderConfig) -> Self { + let (default_header, default_prefix) = match p.kind { + ProviderKind::Anthropic => ("x-api-key", ""), + ProviderKind::Openai => ("Authorization", "Bearer "), + }; + let base = if p.base_url.is_empty() && p.kind == ProviderKind::Anthropic { + "https://api.anthropic.com/v1".to_string() + } else { + expand_env(&p.base_url) + }; + Self { + base_url: base.trim_end_matches('/').to_string(), + api_key: expand_env(&p.api_key), + auth_header: p.auth_header.clone().unwrap_or_else(|| default_header.into()), + auth_prefix: p.auth_prefix.clone().unwrap_or_else(|| default_prefix.into()), + headers: p.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(), + } + } + + pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if !self.api_key.is_empty() { + rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key)); + } + for (k, v) in &self.headers { + rb = rb.header(k, v); + } + rb + } +} + +pub struct ProviderEntry { + pub name: String, + pub provider: Arc, + pub models: Vec, +} + +#[derive(Default)] +pub struct Registry { + pub providers: BTreeMap, +} + +pub fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(20)) + .user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION"))) + .build() + .expect("http client") +} + +impl Registry { + /// Build providers from config. Providers with an empty model list are queried + /// for their models; failures are logged and leave the list empty. + pub async fn build(cfg: &Config) -> Self { + let client = http_client(); + let mut providers = BTreeMap::new(); + for (name, pc) in &cfg.providers { + if !pc.enabled { + continue; + } + let ep = Endpoint::from_config(pc); + let provider: Arc = match pc.kind { + ProviderKind::Anthropic => Arc::new(anthropic::Anthropic::new(client.clone(), ep)), + ProviderKind::Openai => Arc::new(openai::OpenAi::new(client.clone(), ep)), + }; + let mut models: Vec = pc.models.iter().map(|m| m.resolved()).collect(); + if models.is_empty() { + match tokio::time::timeout(std::time::Duration::from_secs(15), provider.list_models()).await { + Ok(Ok(m)) => models = m, + Ok(Err(e)) => tracing::warn!("provider {name}: model discovery failed: {e:#}"), + Err(_) => tracing::warn!("provider {name}: model discovery timed out"), + } + } + providers.insert( + name.clone(), + ProviderEntry { name: name.clone(), provider, models }, + ); + } + Self { providers } + } + + /// Resolve "provider/model". Unknown models of a known provider are allowed + /// with default settings so users can type any model id. + pub fn resolve(&self, spec: &str) -> Result<(Arc, ModelConfig)> { + let (pname, mid) = spec + .split_once('/') + .ok_or_else(|| anyhow!("model must be 'provider/model', got '{spec}'"))?; + let entry = self + .providers + .get(pname) + .ok_or_else(|| anyhow!("unknown or disabled provider '{pname}'"))?; + let model = entry + .models + .iter() + .find(|m| m.id == mid) + .cloned() + .unwrap_or_else(|| ModelConfig { id: mid.to_string(), ..Default::default() }); + Ok((entry.provider.clone(), model)) + } + + pub fn first_model(&self) -> Option { + self.providers + .values() + .find_map(|e| e.models.first().map(|m| format!("{}/{}", e.name, m.id))) + } + + pub fn models_json(&self) -> Value { + let list: Vec = self + .providers + .values() + .map(|e| { + serde_json::json!({ + "provider": e.name, + "models": e.models.iter().map(|m| serde_json::json!({ + "id": m.id, + "name": m.name.clone().unwrap_or_else(|| m.id.clone()), + "context": m.context, + "reasoning": m.reasoning, + "vision": m.vision, + })).collect::>() + }) + }) + .collect(); + Value::Array(list) + } +} + +/// Turn an HTTP error response into a readable error. +pub async fn http_error(resp: reqwest::Response) -> anyhow::Error { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let detail = serde_json::from_str::(&body) + .ok() + .and_then(|v| { + v.pointer("/error/message") + .or_else(|| v.pointer("/0/error/message")) + .or_else(|| v.get("message")) + .and_then(|m| m.as_str().map(String::from)) + }) + .unwrap_or_else(|| body.chars().take(500).collect()); + anyhow!("provider returned HTTP {status}: {detail}") +} diff --git a/server/src/provider/openai.rs b/server/src/provider/openai.rs new file mode 100644 index 0000000..6be9fb4 --- /dev/null +++ b/server/src/provider/openai.rs @@ -0,0 +1,251 @@ +//! OpenAI-compatible chat completions (streaming). Covers Gemini (OpenAI compat), +//! opencode Go, fal/OpenRouter, Ollama, vLLM, llama.cpp, LM Studio, ... + +use super::{ChatRequest, Endpoint, Provider, StreamEvent, http_error}; +use crate::config::ModelConfig; +use crate::types::{Message, Part, Role, Usage}; +use anyhow::{Result, anyhow}; +use async_trait::async_trait; +use eventsource_stream::Eventsource; +use futures::StreamExt; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +pub struct OpenAi { + client: reqwest::Client, + ep: Endpoint, +} + +impl OpenAi { + pub fn new(client: reqwest::Client, ep: Endpoint) -> Self { + Self { client, ep } + } +} + +fn data_url(img: &crate::types::ImageData) -> String { + format!("data:{};base64,{}", img.media_type, img.data) +} + +pub fn to_wire_messages(system: &str, messages: &[Message]) -> Vec { + let mut out = vec![json!({"role": "system", "content": system})]; + for m in messages { + match m.role { + Role::User => { + let mut parts = Vec::new(); + let mut tool_images = Vec::new(); + for p in &m.parts { + match p { + Part::Text { text } if !text.is_empty() => { + parts.push(json!({"type": "text", "text": text})) + } + Part::Summary { text } => parts.push(json!({ + "type": "text", + "text": format!("\n{text}\n") + })), + Part::Image { image } => parts.push(json!({ + "type": "image_url", "image_url": {"url": data_url(image)} + })), + Part::ToolResult { call_id, name, content, images, is_error, .. } => { + let text = if *is_error { format!("ERROR: {content}") } else { content.clone() }; + out.push(json!({ + "role": "tool", "tool_call_id": call_id, "name": name, + "content": if text.is_empty() { "(no output)".into() } else { text } + })); + tool_images.extend(images.iter().cloned()); + } + _ => {} + } + } + // Tool messages cannot carry images: send them as a user message. + if !tool_images.is_empty() { + let mut c = vec![json!({"type": "text", "text": "Images returned by the tool calls above:"})]; + c.extend(tool_images.iter().map(|i| json!({"type": "image_url", "image_url": {"url": data_url(i)}}))); + out.push(json!({"role": "user", "content": c})); + } + if !parts.is_empty() { + out.push(json!({"role": "user", "content": parts})); + } + } + Role::Assistant => { + let mut text = String::new(); + let mut reasoning = String::new(); + let mut calls = Vec::new(); + for p in &m.parts { + match p { + Part::Text { text: t } => text.push_str(t), + Part::Reasoning { text: t, .. } => reasoning.push_str(t), + Part::ToolCall { id, name, input, extra } => { + let mut c = json!({ + "id": id, "type": "function", + "function": {"name": name, "arguments": input.to_string()} + }); + if let Some(e) = extra { + c["extra_content"] = e.clone(); + } + calls.push(c); + } + _ => {} + } + } + let mut msg = json!({"role": "assistant", "content": if text.is_empty() { Value::Null } else { Value::String(text) }}); + if !calls.is_empty() { + msg["tool_calls"] = Value::Array(calls); + } + // Some reasoning models (DeepSeek, Kimi) expect their reasoning back during tool loops. + if !reasoning.is_empty() { + msg["reasoning_content"] = Value::String(reasoning); + } + if msg["content"].is_null() && msg.get("tool_calls").is_none() { + continue; + } + out.push(msg); + } + } + } + out +} + +#[derive(Default)] +struct PendingCall { + id: String, + name: String, + args: String, + extra: Option, +} + +#[async_trait] +impl Provider for OpenAi { + async fn stream( + &self, + req: ChatRequest, + tx: mpsc::Sender, + cancel: CancellationToken, + ) -> Result<()> { + let tools: Vec = req + .tools + .iter() + .map(|t| json!({ + "type": "function", + "function": {"name": t.name, "description": t.description, "parameters": t.input_schema} + })) + .collect(); + let mut body = json!({ + "model": req.model.id, + "messages": to_wire_messages(&req.system, &req.messages), + "stream": true, + "stream_options": {"include_usage": true}, + "max_tokens": req.max_tokens, + }); + if !tools.is_empty() { + body["tools"] = Value::Array(tools); + } + if let Some(effort) = &req.model.effort { + body["reasoning_effort"] = json!(effort); + } + + let rb = self.client.post(format!("{}/chat/completions", self.ep.base_url)).json(&body); + let resp = self.ep.apply(rb).send().await?; + if !resp.status().is_success() { + return Err(http_error(resp).await); + } + + let mut events = resp.bytes_stream().eventsource(); + let mut calls: BTreeMap = BTreeMap::new(); + let mut usage = Usage::default(); + let mut stop_reason = String::from("stop"); + + loop { + let ev = tokio::select! { + _ = cancel.cancelled() => return Ok(()), + ev = events.next() => ev, + }; + let Some(ev) = ev else { break }; + let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?; + if ev.data.trim() == "[DONE]" { + break; + } + if ev.data.trim().is_empty() { + continue; + } + let data: Value = serde_json::from_str(&ev.data)?; + if let Some(err) = data.get("error") { + return Err(anyhow!("provider error: {}", err["message"].as_str().unwrap_or(&err.to_string()))); + } + if let Some(u) = data.get("usage").filter(|u| u.is_object()) { + let cached = u["prompt_tokens_details"]["cached_tokens"].as_u64().unwrap_or(0); + usage.input_tokens = u["prompt_tokens"].as_u64().unwrap_or(0).saturating_sub(cached); + usage.cache_read_tokens = cached; + usage.output_tokens = u["completion_tokens"].as_u64().unwrap_or(0); + } + let Some(choice) = data["choices"].get(0) else { continue }; + let d = &choice["delta"]; + if let Some(t) = d["content"].as_str().filter(|t| !t.is_empty()) { + tx.send(StreamEvent::TextDelta(t.into())).await.ok(); + } + let r = d["reasoning_content"].as_str().or_else(|| d["reasoning"].as_str()); + if let Some(r) = r.filter(|r| !r.is_empty()) { + tx.send(StreamEvent::ReasoningDelta(r.into())).await.ok(); + } + if let Some(tcs) = d["tool_calls"].as_array() { + for (pos, tc) in tcs.iter().enumerate() { + let idx = tc["index"].as_u64().unwrap_or(pos as u64); + let pc = calls.entry(idx).or_default(); + if let Some(id) = tc["id"].as_str().filter(|s| !s.is_empty()) { + pc.id = id.into(); + } + if let Some(n) = tc["function"]["name"].as_str() { + pc.name.push_str(n); + } + if let Some(a) = tc["function"]["arguments"].as_str() { + pc.args.push_str(a); + } + if let Some(e) = tc.get("extra_content") { + pc.extra = Some(e.clone()); + } + } + } + if let Some(f) = choice["finish_reason"].as_str() { + stop_reason = f.to_string(); + } + } + + for (i, pc) in calls { + let input = if pc.args.trim().is_empty() { + json!({}) + } else { + serde_json::from_str(&pc.args).unwrap_or_else(|_| json!({"__invalid_json": pc.args})) + }; + let id = if pc.id.is_empty() { format!("call_{}_{i}", crate::types::new_id()) } else { pc.id }; + tx.send(StreamEvent::ToolCall { id, name: pc.name, input, extra: pc.extra }).await.ok(); + } + tx.send(StreamEvent::Usage(usage)).await.ok(); + tx.send(StreamEvent::Done { stop_reason }).await.ok(); + Ok(()) + } + + async fn list_models(&self) -> Result> { + let rb = self.client.get(format!("{}/models", self.ep.base_url)); + let resp = self.ep.apply(rb).send().await?; + if !resp.status().is_success() { + return Err(http_error(resp).await); + } + let v: Value = resp.json().await?; + let mut models: Vec = v["data"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|m| m["id"].as_str()) + // Gemini lists "models/"; the chat endpoint wants the bare id. + .map(|id| ModelConfig { + id: id.trim_start_matches("models/").to_string(), + ..Default::default() + }) + .collect() + }) + .unwrap_or_default(); + models.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(models) + } +} diff --git a/server/src/runner.rs b/server/src/runner.rs new file mode 100644 index 0000000..aac31ef --- /dev/null +++ b/server/src/runner.rs @@ -0,0 +1,640 @@ +//! The agent loop: stream a model response, run tool calls (with permission +//! checks), feed results back, repeat until the model stops. + +use crate::app::{App, PermissionReply}; +use crate::permission::{Decision, Evaluator, bash_prefix, split_command}; +use crate::prompts::{self, Agent}; +use crate::provider::{ChatRequest, Provider, StreamEvent}; +use crate::store::UsageRecord; +use crate::tools::{self, Tool, ToolCtx}; +use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms}; +use anyhow::{Result, anyhow, bail}; +use futures::future::join_all; +use serde_json::Value; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +const MAX_STEPS: usize = 250; +const MAX_SUBAGENT_DEPTH: usize = 3; + +/// Start processing a user prompt. If the session is busy the prompt is queued +/// and injected at the next step. +pub fn start_prompt(app: Arc, session_id: String, parts: Vec) -> Result<()> { + let text_only: String = parts + .iter() + .filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }) + .collect::>() + .join("\n"); + let queued = app.with_runtime(&session_id, |r| { + if r.cancel.is_some() && parts.iter().all(|p| matches!(p, Part::Text { .. })) { + r.queue.push(text_only.clone()); + true + } else { + false + } + }); + if queued { + return Ok(()); + } + if app.is_busy(&session_id) { + bail!("session is busy"); + } + let cancel = CancellationToken::new(); + app.with_runtime(&session_id, |r| r.cancel = Some(cancel.clone())); + app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: true }); + + tokio::spawn(async move { + let res = async { + let msg = Message::new(Role::User, parts); + app.store.add_message(&session_id, &msg)?; + app.emit(Event::MessageAdded { session_id: session_id.clone(), message: msg }); + maybe_generate_title(app.clone(), &session_id, &text_only); + run_session(app.clone(), &session_id, cancel.clone(), 0).await + } + .await; + if let Err(e) = res { + tracing::warn!("session {session_id}: {e:#}"); + app.emit(Event::Error { session_id: session_id.clone(), message: format!("{e:#}") }); + } + // Prompts queued after the last step start a new turn. + let leftover = app.with_runtime(&session_id, |r| { + r.cancel = None; + std::mem::take(&mut r.queue) + }); + app.emit(Event::SessionStatus { session_id: session_id.clone(), busy: false }); + if !leftover.is_empty() && !cancel.is_cancelled() { + let _ = start_prompt(app, session_id, vec![Part::Text { text: leftover.join("\n\n") }]); + } + }); + Ok(()) +} + +pub fn abort(app: &App, session_id: &str) { + app.with_runtime(session_id, |r| { + if let Some(c) = &r.cancel { + c.cancel(); + } + r.queue.clear(); + }); +} + +/// Messages since the last compaction summary. +fn active_history(all: Vec) -> Vec { + let start = all + .iter() + .rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. }))) + .unwrap_or(0); + all.into_iter().skip(start).collect() +} + +fn normalize_tool_name(s: &str) -> String { + s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_lowercase() +} + +pub fn available_tools(app: &App, agent: &Agent) -> Vec> { + let mut all = tools::builtin_tools(); + all.extend(app.mcp.tools()); + all.into_iter() + .filter(|t| !agent.read_only || t.read_only()) + // Craft tools only where they are useful. + .filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare") || agent.tools.is_some()) + .filter(|t| match &agent.tools { + None => true, + Some(list) => { + let n = normalize_tool_name(t.name()); + list.iter().any(|a| { + let a = normalize_tool_name(a); + a == n || (t.name().starts_with("mcp__") && a == "mcp") + }) + } + }) + .collect() +} + +struct Step { + message: Message, + stop_reason: String, +} + +/// Run the loop for a session until the model stops. Returns the final text. +pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationToken, depth: usize) -> Result { + let mut final_text = String::new(); + for _ in 0..MAX_STEPS { + if cancel.is_cancelled() { + break; + } + let session = app.session(session_id)?; + let cwd = PathBuf::from(&session.cwd); + let agent = prompts::find_agent(&app, &cwd, &session.agent); + let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone()); + let (provider, model) = app.registry().resolve(&model_spec)?; + + // Auto-compaction when the last request used most of the context window. + if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) { + let threshold = app.config().server.auto_compact_threshold; + if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold { + compact(app.clone(), session_id, cancel.clone()).await?; + continue; + } + } + + let tools = available_tools(&app, &agent); + let history = active_history(app.store.messages(session_id)?); + let req = ChatRequest { + system: prompts::system_prompt(&app, &agent, &cwd, &model_spec), + messages: history, + tools: tools.iter().map(|t| t.def()).collect(), + max_tokens: model.max_output.unwrap_or(16_000), + reasoning: true, + model: model.clone(), + }; + let step = stream_step(&app, &session, &agent.name, &model_spec, provider, req, &cancel).await?; + final_text = step + .message + .parts + .iter() + .filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }) + .collect::>() + .join(""); + + let calls: Vec<(String, String, Value)> = step + .message + .parts + .iter() + .filter_map(|p| match p { + Part::ToolCall { id, name, input, .. } => Some((id.clone(), name.clone(), input.clone())), + _ => None, + }) + .collect(); + + if cancel.is_cancelled() { + // Every tool call needs a result, otherwise the history is invalid. + if !calls.is_empty() { + let results = calls + .iter() + .map(|(id, name, _)| tool_result(id, name, "Aborted by user".into(), true, None)) + .collect(); + save_user_parts(&app, session_id, results)?; + } + break; + } + if calls.is_empty() { + match step.stop_reason.as_str() { + "max_tokens" | "length" => app.emit(Event::Error { + session_id: session_id.into(), + message: "The response hit the output token limit (max_output).".into(), + }), + "refusal" | "content_filter" => app.emit(Event::Error { + session_id: session_id.into(), + message: "The model declined to respond (refusal).".into(), + }), + _ => {} + } + break; + } + + let base = ToolCtx { + app: app.clone(), + session_id: session_id.to_string(), + call_id: String::new(), + cwd: cwd.clone(), + cancel: cancel.clone(), + agent: agent.name.clone(), + }; + let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned(); + let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only())); + let mut results = Vec::new(); + if all_read_only { + let futs = calls.iter().map(|(id, name, input)| { + execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth) + }); + results = join_all(futs).await; + } else { + for (id, name, input) in &calls { + results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await); + } + } + // Inject prompts the user sent while we were working. + let queued = app.with_runtime(session_id, |r| std::mem::take(&mut r.queue)); + for q in queued { + results.push(Part::Text { text: q }); + } + save_user_parts(&app, session_id, results)?; + } + Ok(final_text) +} + +fn tool_result(call_id: &str, name: &str, content: String, is_error: bool, meta: Option) -> Part { + Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta } +} + +fn save_user_parts(app: &App, session_id: &str, parts: Vec) -> Result<()> { + let msg = Message::new(Role::User, parts); + app.store.add_message(session_id, &msg)?; + app.emit(Event::MessageAdded { session_id: session_id.into(), message: msg }); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_call( + app: &Arc, + session: &Session, + base: &ToolCtx, + tool: Option>, + call_id: &str, + name: &str, + input: Value, + depth: usize, +) -> Part { + let Some(tool) = tool else { + return tool_result(call_id, name, format!("Unknown or unavailable tool '{name}' in this mode."), true, None); + }; + if let Some(raw) = input.get("__invalid_json") { + return tool_result( + call_id, + name, + format!("Tool input was not valid JSON (possibly truncated). Retry with a smaller input.\n{}", raw.as_str().unwrap_or("").chars().take(300).collect::()), + true, + None, + ); + } + if name == "task" && depth >= MAX_SUBAGENT_DEPTH { + return tool_result(call_id, name, "Maximum subagent depth reached.".into(), true, None); + } + + // Permission check. Approvals are tracked on the top-level session. + let root = app.root_session_id(&session.id); + let cfg = app.config(); + let decision = app.with_runtime(&root, |r| { + Evaluator { cfg: &cfg.permissions, mode: &session.permission_mode, approved: &r.approved }.evaluate(tool.as_ref(), &input) + }); + let ctx = ToolCtx { + app: app.clone(), + session_id: base.session_id.clone(), + call_id: call_id.into(), + cwd: base.cwd.clone(), + cancel: base.cancel.clone(), + agent: base.agent.clone(), + }; + match decision { + Decision::Deny => { + return tool_result(call_id, name, "Denied by the permission rules in the configuration.".into(), true, None); + } + Decision::Ask => { + let diff = tool.preview(&input, &ctx).await; + let (reply, message) = app + .request_permission(&session.id, name, tool.title(&input), input.clone(), diff, base.cancel.clone()) + .await; + match reply { + PermissionReply::Reject => { + let mut text = "The user rejected this tool call.".to_string(); + if let Some(m) = message.filter(|m| !m.trim().is_empty()) { + text.push_str(&format!(" User feedback: {m}")); + } + return tool_result(call_id, name, text, true, None); + } + PermissionReply::Always => app.with_runtime(&root, |r| { + if name == "bash" { + let cmd = input["command"].as_str().unwrap_or(""); + for part in split_command(cmd).unwrap_or_default() { + r.approved.insert(format!("bash:{}", bash_prefix(&part))); + } + } else { + r.approved.insert(name.to_string()); + } + }), + PermissionReply::Once => {} + } + } + Decision::Allow => {} + } + + let res = if name == "task" { + // Pass depth through the agent name channel: subagents run with depth+1. + run_task(app, &ctx, input, depth).await + } else { + tool.run(input, &ctx).await + }; + match res { + Ok(out) => Part::ToolResult { + call_id: call_id.into(), + name: name.into(), + content: out.content, + images: out.images, + is_error: out.is_error, + meta: out.meta, + }, + Err(e) => tool_result(call_id, name, format!("Error: {e:#}"), true, None), + } +} + +async fn run_task(app: &Arc, ctx: &ToolCtx, input: Value, depth: usize) -> Result { + let prompt = tools::arg_str(&input, "prompt")?.to_string(); + let description = input["description"].as_str().unwrap_or("subagent").to_string(); + let agent = input["subagent_type"].as_str().unwrap_or("general").to_string(); + let (child, text) = spawn_subagent(app.clone(), ctx, &agent, &description, prompt, depth + 1).await?; + Ok(tools::ToolOutput::text(text).with_meta(serde_json::json!({"session_id": child, "agent": agent}))) +} + +/// Entry point used by the task tool when called outside the runner. +pub async fn run_subagent(app: Arc, ctx: &ToolCtx, agent: &str, description: &str, prompt: String) -> Result<(String, String)> { + spawn_subagent(app, ctx, agent, description, prompt, 1).await +} + +async fn spawn_subagent( + app: Arc, + ctx: &ToolCtx, + agent_name: &str, + description: &str, + prompt: String, + depth: usize, +) -> Result<(String, String)> { + let parent = app.session(&ctx.session_id)?; + let parent_agent = prompts::find_agent(&app, &ctx.cwd, &parent.agent); + let agents = prompts::agents(&app, &ctx.cwd); + let mut agent = agents + .iter() + .find(|a| a.name == agent_name && a.mode != "primary") + .cloned() + .ok_or_else(|| { + let names: Vec<_> = agents.iter().filter(|a| a.mode != "primary").map(|a| a.name.as_str()).collect(); + anyhow!("unknown subagent '{agent_name}'. Available: {}", names.join(", ")) + })?; + // Read-only parents may only spawn read-only subagents. + if parent_agent.read_only && !agent.read_only { + agent = agents.into_iter().find(|a| a.name == "explore").unwrap_or(agent); + } + let now = now_ms(); + let child = Session { + id: new_id(), + title: description.to_string(), + cwd: parent.cwd.clone(), + model: agent.model.clone().unwrap_or_else(|| parent.model.clone()), + agent: agent.name.clone(), + permission_mode: parent.permission_mode.clone(), + parent_id: Some(parent.id.clone()), + created_at: now, + updated_at: now, + todos: Value::Null, + last_usage: None, + }; + let child = app.save_session(child)?; + let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); + app.store.add_message(&child.id, &msg)?; + app.emit(Event::MessageAdded { session_id: child.id.clone(), message: msg }); + app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: true }); + let res = Box::pin(run_session(app.clone(), &child.id, ctx.cancel.child_token(), depth)).await; + app.emit(Event::SessionStatus { session_id: child.id.clone(), busy: false }); + let text = res?; + Ok((child.id, if text.trim().is_empty() { "(subagent returned no text)".into() } else { text })) +} + +fn is_retryable(e: &anyhow::Error) -> bool { + let s = format!("{e:#}"); + s.contains("HTTP 429") || s.contains("HTTP 5") || s.contains("overloaded") || s.contains("connection") || s.contains("timed out") +} + +/// One model request with streaming to the UI, usage recording and retries. +async fn stream_step( + app: &Arc, + session: &Session, + agent: &str, + model_spec: &str, + provider: Arc, + req: ChatRequest, + cancel: &CancellationToken, +) -> Result { + let mut attempt = 0; + loop { + attempt += 1; + let msg_id = new_id(); + let (tx, mut rx) = mpsc::channel::(256); + let p = provider.clone(); + let r = req.clone(); + let c = cancel.clone(); + let handle = tokio::spawn(async move { p.stream(r, tx, c).await }); + + let started = Instant::now(); + let mut ttft: Option = None; + let mut parts: Vec = Vec::new(); + let mut usage = Usage::default(); + let mut stop_reason = String::new(); + while let Some(ev) = rx.recv().await { + if ttft.is_none() && !matches!(ev, StreamEvent::Usage(_) | StreamEvent::Done { .. }) { + ttft = Some(started.elapsed().as_millis()); + } + match ev { + StreamEvent::TextDelta(t) => { + match parts.last_mut() { + Some(Part::Text { text }) => text.push_str(&t), + _ => parts.push(Part::Text { text: t.clone() }), + } + app.emit(Event::TextDelta { session_id: session.id.clone(), message_id: msg_id.clone(), text: t }); + } + StreamEvent::ReasoningDelta(t) => { + match parts.last_mut() { + Some(Part::Reasoning { text, signature: None }) => text.push_str(&t), + _ => parts.push(Part::Reasoning { text: t.clone(), signature: None }), + } + app.emit(Event::ReasoningDelta { session_id: session.id.clone(), message_id: msg_id.clone(), text: t }); + } + StreamEvent::ReasoningSignature(sig) => match parts.last_mut() { + Some(Part::Reasoning { signature, .. }) if signature.is_none() => *signature = Some(sig), + // Thinking without visible text (display omitted / redacted). + _ => parts.push(Part::Reasoning { text: String::new(), signature: Some(sig) }), + }, + StreamEvent::ToolCall { id, name, input, extra } => { + app.emit(Event::ToolStarted { + session_id: session.id.clone(), + message_id: msg_id.clone(), + call_id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + parts.push(Part::ToolCall { id, name, input, extra }); + } + StreamEvent::Usage(u) => usage = u, + StreamEvent::Done { stop_reason: s } => stop_reason = s, + } + } + let result = handle.await.map_err(|e| anyhow!("provider task failed: {e}"))?; + let duration = started.elapsed().as_millis() as u64; + let (provider_name, model_id) = model_spec.split_once('/').unwrap_or(("", model_spec)); + let record = |error: bool, usage: &Usage| UsageRecord { + ts: now_ms(), + session_id: session.id.clone(), + root_session_id: app.root_session_id(&session.id), + provider: provider_name.into(), + model: model_id.into(), + agent: agent.into(), + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cache_read_tokens: usage.cache_read_tokens, + cache_write_tokens: usage.cache_write_tokens, + ttft_ms: ttft.unwrap_or(duration as u128) as u64, + duration_ms: duration, + cost: req.model.cost(usage), + error, + }; + + if let Err(e) = result { + let _ = app.store.add_usage(&record(true, &usage)); + if parts.is_empty() && attempt < 4 && is_retryable(&e) && !cancel.is_cancelled() { + let wait = std::time::Duration::from_secs(2u64.pow(attempt)); + app.emit(Event::Error { + session_id: session.id.clone(), + message: format!("{e:#} – retrying in {}s", wait.as_secs()), + }); + tokio::time::sleep(wait).await; + continue; + } + return Err(e); + } + if cancel.is_cancelled() { + // Keep what was streamed, but drop unfinished tool calls and unsigned thinking. + parts.retain(|p| matches!(p, Part::Text { .. })); + } + if usage.input_tokens + usage.output_tokens > 0 || !cancel.is_cancelled() { + let _ = app.store.add_usage(&record(false, &usage)); + } + let mut message = Message::new(Role::Assistant, parts); + message.id = msg_id; + message.model = Some(model_spec.to_string()); + message.usage = Some(usage.clone()); + if !message.parts.is_empty() { + app.store.add_message(&session.id, &message)?; + app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() }); + } + let mut s = app.session(&session.id)?; + s.last_usage = Some(usage); + app.save_session(s)?; + return Ok(Step { message, stop_reason }); + } +} + +/// Collect a complete, tool-free answer (titles, summaries). +async fn complete_text(provider: Arc, req: ChatRequest, cancel: CancellationToken) -> Result<(String, Usage)> { + let (tx, mut rx) = mpsc::channel(256); + let handle = tokio::spawn(async move { provider.stream(req, tx, cancel).await }); + let mut text = String::new(); + let mut usage = Usage::default(); + while let Some(ev) = rx.recv().await { + match ev { + StreamEvent::TextDelta(t) => text.push_str(&t), + StreamEvent::Usage(u) => usage = u, + _ => {} + } + } + handle.await??; + Ok((text, usage)) +} + +fn small_model(app: &App, session: &Session) -> String { + let cfg = app.config(); + if !cfg.server.small_model.is_empty() && app.registry().resolve(&cfg.server.small_model).is_ok() { + cfg.server.small_model.clone() + } else { + session.model.clone() + } +} + +fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::config::ModelConfig, usage: &Usage, agent: &str) { + let (p, m) = spec.split_once('/').unwrap_or(("", spec)); + let _ = app.store.add_usage(&UsageRecord { + ts: now_ms(), + session_id: session.id.clone(), + root_session_id: app.root_session_id(&session.id), + provider: p.into(), + model: m.into(), + agent: agent.into(), + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cache_read_tokens: usage.cache_read_tokens, + cache_write_tokens: usage.cache_write_tokens, + ttft_ms: 0, + duration_ms: 0, + cost: model.cost(usage), + error: false, + }); +} + +fn maybe_generate_title(app: Arc, session_id: &str, text: &str) { + let Ok(session) = app.session(session_id) else { return }; + if session.parent_id.is_some() || !session.title.is_empty() { + return; + } + let text: String = text.chars().take(2000).collect(); + tokio::spawn(async move { + let spec = small_model(&app, &session); + let Ok((provider, mut model)) = app.registry().resolve(&spec) else { return }; + model.effort = Some("low".into()); + let req = ChatRequest { + system: "Write a short title (max 6 words) for a coding session that starts with the user's message below. Use the user's language. Reply with the title only, no quotes or punctuation at the end.".into(), + messages: vec![Message::new(Role::User, vec![Part::Text { text }])], + tools: vec![], + max_tokens: 2000, + reasoning: false, + model: model.clone(), + }; + match complete_text(provider, req, CancellationToken::new()).await { + Ok((title, usage)) => { + record_side_usage(&app, &session, &spec, &model, &usage, "title"); + let title = title.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().trim_matches('"').to_string(); + if let (false, Ok(mut s)) = (title.is_empty(), app.session(&session.id)) { + if s.title.is_empty() { + s.title = title.chars().take(80).collect(); + let _ = app.save_session(s); + } + } + } + Err(e) => tracing::debug!("title generation failed: {e:#}"), + } + }); +} + +const COMPACT_PROMPT: &str = "Summarize this coding session so far so that the work can continue seamlessly from the summary alone. Include: the user's goals and explicit requests (verbatim where important), decisions made, files created/changed (with paths) and their current state, commands/tests run and results, open problems, and the exact next steps. Be thorough but concise; use headings and bullet points."; + +/// Replace the active history with a summary (the old messages stay visible in the UI). +pub async fn compact(app: Arc, session_id: &str, cancel: CancellationToken) -> Result<()> { + let session = app.session(session_id)?; + let history = active_history(app.store.messages(session_id)?); + if history.is_empty() { + return Ok(()); + } + let spec = session.model.clone(); + let (provider, model) = app.registry().resolve(&spec)?; + let mut messages = history; + // Strip images to keep the summary request small. + for m in &mut messages { + m.parts.retain(|p| !matches!(p, Part::Image { .. })); + for p in &mut m.parts { + if let Part::ToolResult { images, .. } = p { + images.clear(); + } + } + } + messages.push(Message::new(Role::User, vec![Part::Text { text: COMPACT_PROMPT.into() }])); + let req = ChatRequest { + system: "You are a precise summarizer of software engineering sessions.".into(), + messages, + tools: vec![], + max_tokens: 16_000, + reasoning: false, + model: model.clone(), + }; + let (summary, usage) = complete_text(provider, req, cancel).await?; + record_side_usage(&app, &session, &spec, &model, &usage, "compaction"); + if summary.trim().is_empty() { + bail!("compaction produced an empty summary"); + } + save_user_parts(&app, session_id, vec![Part::Summary { text: summary }])?; + let mut s = app.session(session_id)?; + s.last_usage = None; + // Files must be re-read after compaction before editing. + app.with_runtime(session_id, |r| r.read_files.clear()); + app.save_session(s)?; + Ok(()) +} diff --git a/server/src/store.rs b/server/src/store.rs new file mode 100644 index 0000000..dc1ab67 --- /dev/null +++ b/server/src/store.rs @@ -0,0 +1,317 @@ +//! SQLite persistence for sessions and messages. + +use crate::types::{Message, Session, now_ms}; +use serde::Serialize; +use serde_json::{Value, json}; +use anyhow::Result; +use rusqlite::{Connection, OptionalExtension, params}; +use std::path::Path; +use std::sync::Mutex; + +pub struct Store { + conn: Mutex, +} + +impl Store { + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path)?; + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA foreign_keys=ON; + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + data TEXT NOT NULL, + parent_id TEXT, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS messages_session ON messages(session_id, seq); + -- One row per model request. Kept when sessions are deleted (statistics). + CREATE TABLE IF NOT EXISTS usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + session_id TEXT NOT NULL, + root_session_id TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT NOT NULL, + agent TEXT NOT NULL, + input_tokens INTEGER NOT NULL, + output_tokens INTEGER NOT NULL, + cache_read_tokens INTEGER NOT NULL, + cache_write_tokens INTEGER NOT NULL, + ttft_ms INTEGER NOT NULL, + duration_ms INTEGER NOT NULL, + cost REAL NOT NULL, + error INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS usage_ts ON usage(ts); + CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);", + )?; + Ok(Self { conn: Mutex::new(conn) }) + } + + pub fn save_session(&self, s: &Session) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO sessions (id, data, parent_id, updated_at) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at", + params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at], + )?; + Ok(()) + } + + pub fn get_session(&self, id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let data: Option = conn + .query_row("SELECT data FROM sessions WHERE id = ?1", [id], |r| r.get(0)) + .optional()?; + Ok(data.map(|d| serde_json::from_str(&d)).transpose()?) + } + + /// Top-level sessions (subagent sessions are reachable through their parent). + pub fn list_sessions(&self) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT data FROM sessions WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 500", + )?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for row in rows { + out.push(serde_json::from_str(&row?)?); + } + Ok(out) + } + + pub fn delete_session(&self, id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + // Child sessions (subagents) go with their parent. + conn.execute("DELETE FROM sessions WHERE parent_id = ?1", [id])?; + conn.execute("DELETE FROM sessions WHERE id = ?1", [id])?; + Ok(()) + } + + pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO messages (id, session_id, seq, data) VALUES (?1, ?2, + (SELECT COALESCE(MAX(seq), 0) + 1 FROM messages WHERE session_id = ?2), ?3) + ON CONFLICT(id) DO UPDATE SET data = excluded.data", + params![m.id, session_id, serde_json::to_string(m)?], + )?; + conn.execute( + "UPDATE sessions SET updated_at = ?2 WHERE id = ?1", + params![session_id, now_ms()], + )?; + Ok(()) + } + + pub fn messages(&self, session_id: &str) -> Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = + conn.prepare("SELECT data FROM messages WHERE session_id = ?1 ORDER BY seq")?; + let rows = stmt.query_map([session_id], |r| r.get::<_, String>(0))?; + let mut out = Vec::new(); + for row in rows { + out.push(serde_json::from_str(&row?)?); + } + Ok(out) + } + + /// Remove all messages after (and including) the given one – used for "edit & resend". + pub fn truncate_from(&self, session_id: &str, message_id: &str) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "DELETE FROM messages WHERE session_id = ?1 AND seq >= + (SELECT seq FROM messages WHERE id = ?2 AND session_id = ?1)", + params![session_id, message_id], + )?; + Ok(()) + } +} + +/// One model request, as recorded for statistics. +#[derive(Debug, Clone, Serialize)] +pub struct UsageRecord { + pub ts: i64, + pub session_id: String, + pub root_session_id: String, + pub provider: String, + pub model: String, + pub agent: String, + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_tokens: u64, + pub cache_write_tokens: u64, + /// Time to first token (prefill + queueing), milliseconds. + pub ttft_ms: u64, + pub duration_ms: u64, + pub cost: f64, + pub error: bool, +} + +/// Aggregation SQL shared by all statistics queries. +const AGG: &str = "COUNT(*) AS requests, + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), + COALESCE(SUM(cache_read_tokens),0), COALESCE(SUM(cache_write_tokens),0), + COALESCE(SUM(cost),0.0), COALESCE(SUM(ttft_ms),0), COALESCE(SUM(duration_ms),0), + COALESCE(SUM(error),0)"; + +fn agg_row(r: &rusqlite::Row, offset: usize) -> rusqlite::Result { + let requests: i64 = r.get(offset)?; + let input: i64 = r.get(offset + 1)?; + let output: i64 = r.get(offset + 2)?; + let cache_read: i64 = r.get(offset + 3)?; + let cache_write: i64 = r.get(offset + 4)?; + let cost: f64 = r.get(offset + 5)?; + let ttft: i64 = r.get(offset + 6)?; + let duration: i64 = r.get(offset + 7)?; + let errors: i64 = r.get(offset + 8)?; + let gen_ms = (duration - ttft).max(0); + let prompt = input + cache_read + cache_write; + Ok(json!({ + "requests": requests, + "input_tokens": input, + "output_tokens": output, + "cache_read_tokens": cache_read, + "cache_write_tokens": cache_write, + "cost": cost, + "errors": errors, + "avg_ttft_ms": if requests > 0 { ttft / requests } else { 0 }, + // Output speed: tokens generated per second after the first token. + "output_tps": if gen_ms > 0 { output as f64 * 1000.0 / gen_ms as f64 } else { 0.0 }, + // Prefill speed: prompt tokens processed per second until the first token. + "prefill_tps": if ttft > 0 { prompt as f64 * 1000.0 / ttft as f64 } else { 0.0 }, + "cache_hit_rate": if prompt > 0 { cache_read as f64 / prompt as f64 } else { 0.0 }, + "duration_ms": duration, + })) +} + +impl Store { + pub fn add_usage(&self, u: &UsageRecord) -> Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + ttft_ms, duration_ms, cost, error) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", + params![ + u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent, + u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64, + u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64 + ], + )?; + Ok(()) + } + + /// Statistics for one chat (including its subagents), totals and per model. + pub fn session_stats(&self, root_session_id: &str) -> Result { + let conn = self.conn.lock().unwrap(); + let total = conn.query_row( + &format!("SELECT {AGG} FROM usage WHERE root_session_id = ?1"), + [root_session_id], + |r| agg_row(r, 0), + )?; + let mut stmt = conn.prepare(&format!( + "SELECT provider || '/' || model, {AGG} FROM usage WHERE root_session_id = ?1 + GROUP BY 1 ORDER BY 1" + ))?; + let by_model = stmt + .query_map([root_session_id], |r| { + let mut v = agg_row(r, 1)?; + v["model"] = json!(r.get::<_, String>(0)?); + Ok(v) + })? + .collect::>>()?; + Ok(json!({"total": total, "by_model": by_model})) + } + + /// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets + /// into the viewer's local time. + pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64) -> Result { + let conn = self.conn.lock().unwrap(); + let shift = tz_offset_min * 60_000; + let total = conn.query_row( + &format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2"), + params![from, to], + |r| agg_row(r, 0), + )?; + + let grouped = |key_sql: &str, key_name: &str| -> Result> { + let mut stmt = conn.prepare(&format!( + "SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3 GROUP BY 1 ORDER BY 1" + ))?; + let rows = stmt + .query_map(params![from, to, shift], |r| { + let mut v = agg_row(r, 1)?; + v[key_name] = match r.get_ref(0)? { + rusqlite::types::ValueRef::Integer(i) => json!(i), + rusqlite::types::ValueRef::Text(t) => json!(String::from_utf8_lossy(t)), + _ => Value::Null, + }; + Ok(v) + })? + .collect::>>()?; + Ok(rows) + }; + + let day_sql = "strftime('%Y-%m-%d', (ts + ?3) / 1000, 'unixepoch')"; + let by_day = grouped(day_sql, "day")?; + let by_model = grouped("provider || '/' || model", "model")?; + let by_provider = grouped("provider", "provider")?; + let by_agent = grouped("agent", "agent")?; + // Heatmap: weekday (0 = Sunday) x hour of day. + let heatmap = grouped( + "CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \ + CAST(strftime('%H', (ts + ?3) / 1000, 'unixepoch') AS INTEGER)", + "slot", + )?; + let by_day_model = { + let mut stmt = conn.prepare(&format!( + "SELECT {day_sql}, provider || '/' || model, SUM(cost), + SUM(input_tokens + cache_read_tokens + cache_write_tokens), SUM(output_tokens) + FROM usage WHERE ts >= ?1 AND ts < ?2 GROUP BY 1, 2 ORDER BY 1" + ))?; + stmt.query_map(params![from, to, shift], |r| { + Ok(json!({ + "day": r.get::<_, String>(0)?, + "model": r.get::<_, String>(1)?, + "cost": r.get::<_, f64>(2)?, + "input_tokens": r.get::<_, i64>(3)?, + "output_tokens": r.get::<_, i64>(4)?, + })) + })? + .collect::>>()? + }; + // Most expensive chats in the range. + let top_sessions = { + let mut stmt = conn.prepare(&format!( + "SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG} + FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id + WHERE u.ts >= ?1 AND u.ts < ?2 + GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15" + ))?; + stmt.query_map(params![from, to], |r| { + let mut v = agg_row(r, 2)?; + v["session_id"] = json!(r.get::<_, String>(0)?); + v["title"] = json!(r.get::<_, String>(1)?); + Ok(v) + })? + .collect::>>()? + }; + Ok(json!({ + "total": total, + "by_day": by_day, + "by_day_model": by_day_model, + "by_model": by_model, + "by_provider": by_provider, + "by_agent": by_agent, + "heatmap": heatmap, + "top_sessions": top_sessions, + })) + } +} diff --git a/server/src/tools/agent.rs b/server/src/tools/agent.rs new file mode 100644 index 0000000..9ee4304 --- /dev/null +++ b/server/src/tools/agent.rs @@ -0,0 +1,175 @@ +//! Tools that interact with the harness itself: todo list, questions to the +//! user and subagents. + +use super::{Tool, ToolCtx, ToolOutput, arg_str}; +use crate::types::Question; +use anyhow::{Result, bail}; +use async_trait::async_trait; +use serde_json::{Value, json}; + +pub struct TodoWrite; + +#[async_trait] +impl Tool for TodoWrite { + fn name(&self) -> &str { + "todo_write" + } + fn description(&self) -> String { + "Maintain a task list for the current work, shown live to the user. Send the complete \ + list each time. Use it for multi-step tasks: exactly one item in_progress at a time, \ + mark items completed as soon as they are done." + .into() + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "todos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {"type": "string"}, + "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]} + }, + "required": ["content", "status"] + } + } + }, + "required": ["todos"] + }) + } + fn read_only(&self) -> bool { + true + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let todos = input.get("todos").cloned().unwrap_or(json!([])); + if !todos.is_array() { + bail!("todos must be an array"); + } + ctx.app.set_todos(&ctx.session_id, todos.clone())?; + let open = todos.as_array().unwrap().iter().filter(|t| t["status"] != "completed").count(); + Ok(ToolOutput::text(format!("Todo list updated ({open} open).")).with_meta(json!({"todos": todos}))) + } +} + +// --------------------------------------------------------------------------- + +pub struct AskUser; + +#[async_trait] +impl Tool for AskUser { + fn name(&self) -> &str { + "ask_user" + } + fn description(&self) -> String { + "Ask the user one or more questions and wait for the answers. Use this only when you are \ + blocked on a decision that is genuinely the user's (requirements, preferences, choices \ + between valid approaches) – not for things you can find out yourself. Offer 2-4 concrete \ + options per question; the user can always answer with free text instead." + .into() + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "questions": { + "type": "array", + "minItems": 1, + "maxItems": 4, + "items": { + "type": "object", + "properties": { + "question": {"type": "string"}, + "header": {"type": "string", "description": "Very short label (max 12 chars)"}, + "multi_select": {"type": "boolean"}, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "description": {"type": "string"} + }, + "required": ["label"] + } + } + }, + "required": ["question"] + } + } + }, + "required": ["questions"] + }) + } + fn read_only(&self) -> bool { + true + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let questions: Vec = serde_json::from_value(input["questions"].clone())?; + if questions.is_empty() { + bail!("at least one question is required"); + } + let answers = ctx + .app + .ask_user(&ctx.session_id, "agent", questions.clone(), ctx.cancel.clone()) + .await?; + let Some(answers) = answers else { + return Ok(ToolOutput::error("The user dismissed the questions without answering.")); + }; + let mut out = String::from("The user answered:\n"); + for q in &questions { + let a = answers.get(&q.question).map(|v| match v { + Value::String(s) => s.clone(), + Value::Array(a) => a.iter().filter_map(|x| x.as_str()).collect::>().join(", "), + other => other.to_string(), + }); + out.push_str(&format!("- {}\n → {}\n", q.question, a.unwrap_or_else(|| "(no answer)".into()))); + } + Ok(ToolOutput::text(out).with_meta(json!({"answers": answers}))) + } +} + +// --------------------------------------------------------------------------- + +pub struct Task; + +#[async_trait] +impl Tool for Task { + fn name(&self) -> &str { + "task" + } + fn description(&self) -> String { + "Launch a subagent to handle an independent, multi-step task in its own context (e.g. a \ + broad codebase search, a focused review). The subagent only knows what you put in the \ + prompt – write a complete, self-contained brief. Its final message is returned to you. \ + Several task calls in one response run in parallel." + .into() + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "description": {"type": "string", "description": "Short (3-5 words) task description"}, + "prompt": {"type": "string", "description": "The full brief for the subagent"}, + "subagent_type": {"type": "string", "description": "Subagent to use (default: general)"} + }, + "required": ["description", "prompt"] + }) + } + fn read_only(&self) -> bool { + // Read-only agents (plan, explore) can spawn read-only subagents; the runner enforces it. + true + } + fn title(&self, input: &Value) -> String { + format!("Subagent: {}", input["description"].as_str().unwrap_or("task")) + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let prompt = arg_str(&input, "prompt")?.to_string(); + let description = input["description"].as_str().unwrap_or("subagent").to_string(); + let agent = input["subagent_type"].as_str().unwrap_or("general").to_string(); + let (child_id, result) = + crate::runner::run_subagent(ctx.app.clone(), ctx, &agent, &description, prompt).await?; + Ok(ToolOutput::text(result).with_meta(json!({"session_id": child_id, "agent": agent}))) + } +} diff --git a/server/src/tools/bash.rs b/server/src/tools/bash.rs new file mode 100644 index 0000000..38a2fe0 --- /dev/null +++ b/server/src/tools/bash.rs @@ -0,0 +1,140 @@ +//! Shell command execution with timeout, cancellation and live output. + +use super::{Tool, ToolCtx, ToolOutput, arg_str, truncate}; +use anyhow::Result; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +const DEFAULT_TIMEOUT_MS: u64 = 120_000; +const MAX_TIMEOUT_MS: u64 = 600_000; +const MAX_OUTPUT: usize = 30_000; + +pub struct Bash; + +#[async_trait] +impl Tool for Bash { + fn name(&self) -> &str { + "bash" + } + fn description(&self) -> String { + "Run a shell command (bash) in the session working directory. stdout and stderr are \ + combined. Default timeout 2 minutes, max 10 minutes (timeout_ms). The working directory \ + does not persist between calls – use absolute paths or `cd dir && ...`. Prefer the \ + dedicated read/edit/glob/grep tools over cat/sed/find/grep." + .into() + } + fn schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "command": {"type": "string"}, + "description": {"type": "string", "description": "5-10 word description of what the command does"}, + "timeout_ms": {"type": "integer"} + }, + "required": ["command"] + }) + } + fn title(&self, input: &Value) -> String { + input["description"] + .as_str() + .map(String::from) + .unwrap_or_else(|| format!("Run: {}", input["command"].as_str().unwrap_or("?"))) + } + async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { + let command = arg_str(&input, "command")?; + let timeout = Duration::from_millis( + input["timeout_ms"].as_u64().unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS), + ); + let mut cmd = Command::new("bash"); + cmd.arg("-c") + .arg(format!("{command} 2>&1")) + .current_dir(&ctx.cwd) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .env("CODECRAFT", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .env("PAGER", "cat") + .kill_on_drop(true); + // Own process group so the whole tree can be killed on abort/timeout. + cmd.process_group(0); + let mut child = cmd.spawn()?; + let pid = child.id(); + let stdout = child.stdout.take().unwrap(); + // split() instead of lines(): non-UTF-8 output must not end the read loop. + let mut lines = BufReader::new(stdout).split(b'\n'); + let mut output = String::new(); + let started = Instant::now(); + let mut last_progress = Instant::now(); + let deadline = tokio::time::sleep(timeout); + tokio::pin!(deadline); + + let kill_group = |pid: Option| { + if let Some(pid) = pid { + // SAFETY: plain syscall; negative pid targets the process group. + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } + } + }; + + let status = loop { + tokio::select! { + line = lines.next_segment() => match line { + Ok(Some(l)) => { + if output.len() < MAX_OUTPUT * 4 { + output.push_str(&String::from_utf8_lossy(&l)); + output.push('\n'); + } + if last_progress.elapsed() > Duration::from_millis(300) { + ctx.progress(tail(&output, 4000)); + last_progress = Instant::now(); + } + } + Ok(None) | Err(_) => break child.wait().await?, + }, + _ = &mut deadline => { + kill_group(pid); + let _ = child.wait().await; + output.push_str(&format!("\n[command timed out after {}s and was killed]", timeout.as_secs())); + return Ok(ToolOutput::error(truncate(&output, MAX_OUTPUT))); + } + _ = ctx.cancel.cancelled() => { + kill_group(pid); + let _ = child.wait().await; + return Ok(ToolOutput::error("[command aborted by user]")); + } + } + }; + + let code = status.code().unwrap_or(-1); + let mut text = truncate(output.trim_end(), MAX_OUTPUT); + if text.is_empty() { + text = "(no output)".into(); + } + if code != 0 { + text.push_str(&format!("\n[exit code {code}]")); + } + Ok(ToolOutput { + content: text, + is_error: code != 0, + meta: Some(json!({"exit_code": code, "duration_ms": started.elapsed().as_millis() as u64})), + ..Default::default() + }) + } +} + +fn tail(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut start = s.len() - max; + while !s.is_char_boundary(start) { + start += 1; + } + s[start..].to_string() +} diff --git a/server/src/tools/craft.rs b/server/src/tools/craft.rs new file mode 100644 index 0000000..f8848dd --- /dev/null +++ b/server/src/tools/craft.rs @@ -0,0 +1,244 @@ +//! Craft mode tools: study a target in a headless browser and compare a build +//! with the original visually. + +use super::{Tool, ToolCtx, ToolOutput, arg_str}; +use crate::types::ImageData; +use anyhow::{Context, Result, bail}; +use async_trait::async_trait; +use base64::Engine; +use image::{GenericImageView, ImageFormat, Rgba, RgbaImage}; +use serde_json::{Value, json}; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::process::Command; + +const SNAPSHOT_HEIGHT: u32 = 2000; +/// Long-side limit for images sent to the model. +const MAX_IMAGE_SIDE: u32 = 1568; + +fn png_for_model(img: &image::DynamicImage) -> Result { + let (w, h) = img.dimensions(); + let img = if w.max(h) > MAX_IMAGE_SIDE { + img.resize(MAX_IMAGE_SIDE, MAX_IMAGE_SIDE, image::imageops::FilterType::Triangle) + } else { + img.clone() + }; + let mut buf = Vec::new(); + img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)?; + Ok(ImageData { + media_type: "image/png".into(), + data: base64::engine::general_purpose::STANDARD.encode(&buf), + }) +} + +fn slug(s: &str) -> String { + let mut out: String = s + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) + .collect(); + while out.contains("--") { + out = out.replace("--", "-"); + } + out.trim_matches('-').chars().take(60).collect() +} + +async fn chromium(ctx: &ToolCtx, args: &[String]) -> Result { + let bin = ctx.app.config().craft.chromium.clone(); + let fut = Command::new(&bin) + .args([ + "--headless=new", + "--no-sandbox", + "--disable-gpu", + "--hide-scrollbars", + "--mute-audio", + "--disable-dev-shm-usage", + "--virtual-time-budget=8000", + ]) + .args(args) + .kill_on_drop(true) + .output(); + tokio::select! { + r = tokio::time::timeout(Duration::from_secs(60), fut) => { + r.context("chromium timed out")?.with_context(|| format!("could not start '{bin}' (set craft.chromium in the config)")) + } + _ = ctx.cancel.cancelled() => bail!("aborted"), + } +} + +pub struct BrowserSnapshot; + +#[async_trait] +impl Tool for BrowserSnapshot { + fn name(&self) -> &str { + "browser_snapshot" + } + fn description(&self) -> String { + "Open a URL in a headless browser and capture what a visitor sees: screenshots at the \ + configured screen widths (or the given ones) plus the visible text. Works for public \ + sites and for local builds (http://localhost:PORT or file:///path). Screenshots are saved \ + under .craft/snapshots/