Starbot Source Code

Complete Source Code Collection

Generated on 2026-02-24

Starbot Source Code Collection

This EPUB contains the complete source code for the Starbot project, including:

Starbot Cloud (Web UI) - Next.js application

Starbot API - Backend API service

Starbot TUI - Terminal User Interface (Rust)

Starbot Cloud (Web UI)

.gitignore

CLAUDE.md

Dockerfile

README.md

deploy.sh

deploy/nginx-starbot.cloud.conf

next-env.d.ts

next.config.ts

package-lock.json

package.json

scripts/deploy.sh

src/app/account/page.tsx

src/app/admin/identity/page.tsx

src/app/admin/layout.tsx

src/app/admin/page.tsx

src/app/admin/providers/page.tsx

src/app/api/auth/session/route.ts

src/app/globals.css

src/app/layout.tsx

src/app/login/page.tsx

src/app/page.tsx

src/app/signup/page.tsx

src/components/account-menu.tsx

src/components/admin/admin-shell.tsx

src/components/chat/chat-input.tsx

src/components/chat/chat-view.tsx

src/components/chat/code-block.tsx

src/components/chat/markdown-content.tsx

src/components/chat/message-actions.tsx

src/components/chat/message-list.tsx

src/components/chat/message-skeleton.tsx

src/components/error-boundary.tsx

src/components/logs-panel.tsx

src/components/network-status.tsx

src/components/providers/query-provider.tsx

src/components/providers/toast-provider.tsx

src/components/settings-panel.tsx

src/components/sidebar.tsx

src/components/ui/button.tsx

src/components/ui/card.tsx

src/components/ui/dialog.tsx

src/components/ui/dropdown-menu.tsx

src/components/ui/input.tsx

src/components/ui/scroll-area.tsx

src/components/ui/skeleton.tsx

src/components/ui/sonner.tsx

src/components/ui/textarea.tsx

src/components/ui/tooltip.tsx

src/hooks/use-chat-stream.ts

src/lib/api.ts

src/lib/api/chats.ts

src/lib/api/folders.ts

src/lib/api/memory.ts

src/lib/api/messages.ts

src/lib/api/projects.ts

src/lib/auth-session.ts

src/lib/config.ts

src/lib/server/admin-cookie.ts

src/lib/types.ts

src/lib/utils.ts

src/proxy.ts

src/store/ui-store.ts

tsconfig.json

Starbot API

.env

.env.example

.gitignore

CLAUDE.md

Dockerfile

deploy.sh

package-lock.json

package.json

scripts/api-tui.ts

src/db.ts

src/env.ts

src/index.ts

src/providers/azure-openai.ts

src/providers/bedrock.ts

src/providers/cloudflare.ts

src/providers/deepseek.ts

src/providers/index.ts

src/providers/kimi.ts

src/providers/types.ts

src/providers/vertex.ts

src/routes/__tests__/generation.test.ts

src/routes/__tests__/inference.test.ts

src/routes/__tests__/memory.test.ts

src/routes/__tests__/messages.test.ts

src/routes/__tests__/projects.test.ts

src/routes/__tests__/protection.test.ts

src/routes/auth.ts

src/routes/chats.ts

src/routes/folders.ts

src/routes/generation.ts

src/routes/inference.ts

src/routes/memory.ts

src/routes/messages.ts

src/routes/models.ts

src/routes/projects.ts

src/routes/tasks.ts

src/routes/workspaces.ts

src/security/route-guards.ts

src/services/__tests__/chunking.test.ts

src/services/__tests__/interpreter.test.ts

src/services/__tests__/retrieval.test.ts

src/services/chunking.ts

src/services/codex-router.ts

src/services/embeddings.ts

src/services/filesystem-router.ts

src/services/interpreter.ts

src/services/message-preprocessor.ts

src/services/model-catalog.ts

src/services/orchestrator/executor.ts

src/services/orchestrator/index.ts

src/services/orchestrator/orchestrator.ts

src/services/orchestrator/parser.ts

src/services/orchestrator/types.ts

src/services/retrieval.ts

src/services/task-manager/TaskManager.ts

src/services/task-manager/index.ts

src/services/task-manager/types.ts

src/services/tools/__tests__/registry.test.ts

src/services/tools/calculator-tool.ts

src/services/tools/code-exec-tool.ts

src/services/tools/file-read-tool.ts

src/services/tools/fs-advanced-ops-tool.ts

src/services/tools/fs-edit-file-tool.ts

src/services/tools/fs-glob-tool.ts

src/services/tools/fs-grep-tool.ts

src/services/tools/fs-smart-edit-tool.ts

src/services/tools/fs-write-file-tool.ts

src/services/tools/index.ts

src/services/tools/registry.ts

src/services/tools/sandbox-exec-tool.ts

src/services/tools/shell-exec-tool.ts

src/services/tools/types.ts

src/services/tools/web-search-tool.ts

src/services/triage/index.ts

src/services/triage/rules.ts

src/services/triage/signals.ts

src/services/triage/types.ts

src/services/web-search.ts

tsconfig.json

vitest.config.ts

Starbot TUI

Cargo.lock

Cargo.toml

README.md

cli/.gitignore

cli/Cargo.lock

cli/Cargo.toml

cli/README.md

cli/remove_duplicates.py

cli/scripts/smoke.sh

cli/scripts/starbott-dev.sh

cli/src/api.rs

cli/src/app.rs

cli/src/commands/auth.rs

cli/src/commands/billing.rs

cli/src/commands/chat.rs

cli/src/commands/config.rs

cli/src/commands/health.rs

cli/src/commands/mod.rs

cli/src/commands/tools.rs

cli/src/commands/tui.rs

cli/src/commands/usage.rs

cli/src/commands/whoami.rs

cli/src/commands/workspaces.rs

cli/src/config.rs

cli/src/cute.rs

cli/src/errors.rs

cli/src/main.rs

cli/src/output.rs

cli/src/parse/mod.rs

cli/src/parse/response.rs

cli/src/tui/handlers/async_ops.rs

cli/src/tui/handlers/key.rs

cli/src/tui/handlers/message.rs

cli/src/tui/handlers/mod.rs

cli/src/tui/mod.rs

cli/src/tui/types.rs

remove_duplicates.py

scripts/smoke.sh

scripts/starbott-dev.sh

src/api.rs

src/app.rs

src/commands/auth.rs

src/commands/billing.rs

src/commands/chat.rs

src/commands/config.rs

src/commands/health.rs

src/commands/mod.rs

src/commands/tools.rs

src/commands/tui.rs

src/commands/usage.rs

src/commands/whoami.rs

src/commands/workspaces.rs

src/config.rs

src/cute.rs

src/errors.rs

src/main.rs

src/output.rs

src/parse/mod.rs

src/parse/response.rs

src/tui/handlers/async_ops.rs

src/tui/handlers/key.rs

src/tui/handlers/message.rs

src/tui/handlers/mod.rs

src/tui/mod.rs

src/tui/types.rs

Generated on 2026-02-24 19:43:19

vitest.config.ts

Path: starbot_api/vitest.config.ts

Size: 507 bytes

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.test.ts'],
    exclude: ['dist/**', 'node_modules/**'],
    testTimeout: 30000, // Increase timeout for integration tests
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'dist/',
        'prisma/',
        '**/*.config.ts',
        '**/*.d.ts',
      ],
    },
  },
});

.gitignore

Path: starbot_api/.gitignore

Size: 220 bytes

# Dependencies
node_modules/

# Build output
dist/

# Environment files
.env
.env.runtime

# Database
prisma/*.db
prisma/*.db-journal
*.db
*.db-journal

# OS
.DS_Store

# Debug logs
npm-debug.log*

# IDE
.vscode/
.idea/

Dockerfile

Path: starbot_api/Dockerfile

Size: 460 bytes

FROM node:22-slim

# Install OpenSSL 1.1 for Prisma
RUN apt-get update -y && apt-get install -y openssl libssl3 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Copy package files
COPY package*.json ./

# Install ALL dependencies (including dev deps for pino-pretty)
RUN npm ci

# Copy built code
COPY dist ./dist
COPY prisma ./prisma

# Generate Prisma client
RUN npx prisma generate

# Expose API port
EXPOSE 3737

# Run the API
CMD ["node", "dist/index.js"]

deploy.sh

Path: starbot_api/deploy.sh

Size: 1802 bytes

#!/bin/bash
# Deploy Starbot API
# Usage: ./deploy.sh
set -e

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo -e "${YELLOW}Deploying Starbot API from $REPO_DIR${NC}"

# Step 1: Install dependencies and build
echo -e "\n${GREEN}1. Building API...${NC}"
cd "$REPO_DIR"
sudo -u stella bash -lc "
  cd '$REPO_DIR'
  if [ -f .env ]; then set -a; source ./.env; set +a; fi
  npm ci
  npm run build
"

if [ ! -f "$REPO_DIR/dist/index.js" ]; then
    echo -e "${RED}Build failed - dist/index.js not found${NC}"
    exit 1
fi
echo -e "${GREEN}Build successful${NC}"

# Step 2: Push database schema
echo -e "\n${GREEN}2. Syncing database schema...${NC}"
sudo -u stella bash -lc "
  cd '$REPO_DIR'
  if [ -f .env ]; then set -a; source ./.env; set +a; fi
  npx prisma db push
"
echo -e "${GREEN}Schema synced${NC}"

# Step 3: Install systemd service
echo -e "\n${GREEN}3. Installing systemd service...${NC}"
sudo cp "$REPO_DIR/deploy/starbot-api.service" /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable starbot-api
echo -e "${GREEN}Service installed${NC}"

# Step 4: Restart
echo -e "\n${GREEN}4. Restarting starbot-api...${NC}"
sudo systemctl restart starbot-api
sleep 2

if sudo systemctl is-active --quiet starbot-api; then
    echo -e "${GREEN}starbot-api is running${NC}"
else
    echo -e "${RED}starbot-api failed to start${NC}"
    echo -e "${YELLOW}View logs: sudo journalctl -u starbot-api -n 50${NC}"
    exit 1
fi

# Step 5: Verify
echo -e "\n${GREEN}5. Verifying...${NC}"
if curl -sf http://localhost:3737/v1/health > /dev/null; then
    echo -e "${GREEN}API health check passed${NC}"
else
    echo -e "${RED}API health check failed${NC}"
fi

echo -e "\n${GREEN}API deployment complete!${NC}"

.env

Path: starbot_api/.env

Size: 2311 bytes

# Starbot_API Environment Configuration
# Copy this file to .env and fill in your credentials

# ===== Server Configuration =====
PORT=3737
HOST=127.0.0.1
NODE_ENV=development
LOG_LEVEL=info
DATABASE_URL=file:./dev.db

# ===== Kimi/Moonshot =====
# DISABLED
# MOONSHOT_API_KEY=

# ===== Google Vertex AI =====
# DISABLED
# VERTEX_PROJECT_ID=
# VERTEX_LOCATION=
# VERTEX_ALLOWED_MODELS=
# GOOGLE_APPLICATION_CREDENTIALS=

# ===== Azure AI Services (OpenAI) =====
# Setup: https://portal.azure.com/
AZURE_OPENAI_ENDPOINT=https://aiproject-five-resource.cognitiveservices.azure.com
AZURE_OPENAI_API_KEY=DS1iXmAVGq3nlHlenvVjUSa9MN564z7s25G0iqYUb7VKM86F3DxFJQQJ99CAACHYHv6XJ3w3AAAAACOGDhtE
# Comma-separated list of deployed model names (chat models only)
AZURE_OPENAI_MODELS=gpt-4.1,gpt-5.2-chat,gpt-5.1-codex-mini,DeepSeek-R1,Mistral-Large-3,Kimi-K2-Thinking,Kimi-K2.5,claude-haiku-4-5,claude-sonnet-4-5,claude-sonnet-4-5-2
# Currently using DeepSeek-R1 only
AZURE_ALLOWED_DEPLOYMENTS=DeepSeek-R1

# ===== Official DeepSeek API (for testing) =====
DEEPSEEK_API_KEY=sk-2c2703f54e5a4932aee2bd7fd7568be3

# ===== AWS Bedrock =====
# DISABLED - keeping credentials for later
# AWS_ACCESS_KEY_ID=AKIAX5P5TXIROKIZZ7UF
# AWS_SECRET_ACCESS_KEY=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
# AWS_REGION=us-east-1
# BEDROCK_REGION=us-east-1

# ===== Cloudflare Workers AI =====
# DISABLED - keeping credentials for later
# CLOUDFLARE_ACCOUNT_ID=1ecd4a45f29ddd856accd57ac0478aeb
# CLOUDFLARE_API_TOKEN=DuE8gfIjPh3YxOKyIzLVixKeEketk_uav8Zq5DQJ
INTERPRETER_ENABLED=false
# INTERPRETER_MODEL=@cf/mistralai/mistral-small-3.1-24b-instruct
# INTERPRETER_MAX_TOKENS=220

# ===== Features =====
# Enable LLM-based triage (uses cheapest model to improve routing)
TRIAGE_MODEL_ENABLED=false

# Enable tool execution (file.read, file.dir, web.search, etc.)
TOOLS_ENABLED=true

# Enable web search (requires API key)
WEB_SEARCH_ENABLED=true
WEB_SEARCH_API_KEY=your_search_api_key
BRAVE_SEARCH_API_KEY=BSABRgZpUhVQ7sDWl88L0FehziH0ZvJ
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_RUN_PER_WINDOW=8
RATE_LIMIT_INFERENCE_PER_WINDOW=30
RATE_LIMIT_COMPLETION_PER_WINDOW=60

# ===== Quick Start =====
# Minimum configuration to get started (Kimi only):
# 1. Copy this file to .env
# 2. Add your MOONSHOT_API_KEY
# 3. Run: pnpm dev
# 4. Test: ./test-api.sh

package.json

Path: starbot_api/package.json

Size: 1373 bytes

{
  "name": "starbot-api",
  "version": "1.0.0",
  "description": "Starbot API - The Brain (model routing, tools, storage)",
  "main": "dist/index.js",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "lint": "eslint src --ext .ts",
    "db:generate": "prisma generate",
    "db:migrate": "prisma migrate dev",
    "db:push": "prisma db push",
    "db:studio": "prisma studio",
    "api:tui": "tsx scripts/api-tui.ts"
  },
  "keywords": [
    "starbot",
    "ai",
    "llm",
    "api"
  ],
  "author": "",
  "license": "MIT",
  "engines": {
    "node": ">=20.0.0"
  },
  "dependencies": {
    "@aws-sdk/client-bedrock-runtime": "^3.700.0",
    "@azure/openai": "^2.0.0",
    "@fastify/cors": "^10.0.1",
    "@fastify/websocket": "^11.0.1",
    "@google-cloud/vertexai": "^1.9.1",
    "@prisma/client": "^5.22.0",
    "dotenv": "^17.2.4",
    "fastify": "^5.7.4",
    "mathjs": "^15.1.1",
    "nanoid": "^5.0.0",
    "openai": "^6.22.0",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@types/node": "^22.19.11",
    "@vitest/coverage-v8": "^4.0.18",
    "pino-pretty": "^13.1.3",
    "prisma": "^5.22.0",
    "tsx": "^4.19.2",
    "typescript": "^5.7.3",
    "vitest": "^4.0.18"
  }
}

package-lock.json

Path: starbot_api/package-lock.json

Size: 172263 bytes

{
  "name": "starbot-api",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "starbot-api",
      "version": "1.0.0",
      "license": "MIT",
      "dependencies": {
        "@aws-sdk/client-bedrock-runtime": "^3.700.0",
        "@azure/openai": "^2.0.0",
        "@fastify/cors": "^10.0.1",
        "@fastify/websocket": "^11.0.1",
        "@google-cloud/vertexai": "^1.9.1",
        "@prisma/client": "^5.22.0",
        "dotenv": "^17.2.4",
        "fastify": "^5.7.4",
        "mathjs": "^15.1.1",
        "nanoid": "^5.0.0",
        "openai": "^6.22.0",
        "zod": "^3.24.1"
      },
      "devDependencies": {
        "@types/node": "^22.19.11",
        "@vitest/coverage-v8": "^4.0.18",
        "pino-pretty": "^13.1.3",
        "prisma": "^5.22.0",
        "tsx": "^4.19.2",
        "typescript": "^5.7.3",
        "vitest": "^4.0.18"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-crypto/crc32": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
      "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-crypto/util": "^5.2.0",
        "@aws-sdk/types": "^3.222.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=16.0.0"
      }
    },
    "node_modules/@aws-crypto/sha256-browser": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
      "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-crypto/sha256-js": "^5.2.0",
        "@aws-crypto/supports-web-crypto": "^5.2.0",
        "@aws-crypto/util": "^5.2.0",
        "@aws-sdk/types": "^3.222.0",
        "@aws-sdk/util-locate-window": "^3.0.0",
        "@smithy/util-utf8": "^2.0.0",
        "tslib": "^2.6.2"
      }
    },
    "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
      "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
      "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/is-array-buffer": "^2.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
      "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/util-buffer-from": "^2.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@aws-crypto/sha256-js": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
      "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-crypto/util": "^5.2.0",
        "@aws-sdk/types": "^3.222.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=16.0.0"
      }
    },
    "node_modules/@aws-crypto/supports-web-crypto": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
      "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      }
    },
    "node_modules/@aws-crypto/util": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
      "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.222.0",
        "@smithy/util-utf8": "^2.0.0",
        "tslib": "^2.6.2"
      }
    },
    "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
      "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
      "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/is-array-buffer": "^2.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
      "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/util-buffer-from": "^2.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@aws-sdk/client-bedrock-runtime": {
      "version": "3.989.0",
      "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.989.0.tgz",
      "integrity": "sha512-qVa5B0wXjIuPRhX1dcZo1sa9Y4ycI9tiqK7B4FLok67gUWckiKmEf1xQDFrTmc2eCK5g0CTaeiRdbeM1eWmW1Q==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-crypto/sha256-browser": "5.2.0",
        "@aws-crypto/sha256-js": "5.2.0",
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/credential-provider-node": "^3.972.8",
        "@aws-sdk/eventstream-handler-node": "^3.972.5",
        "@aws-sdk/middleware-eventstream": "^3.972.3",
        "@aws-sdk/middleware-host-header": "^3.972.3",
        "@aws-sdk/middleware-logger": "^3.972.3",
        "@aws-sdk/middleware-recursion-detection": "^3.972.3",
        "@aws-sdk/middleware-user-agent": "^3.972.9",
        "@aws-sdk/middleware-websocket": "^3.972.6",
        "@aws-sdk/region-config-resolver": "^3.972.3",
        "@aws-sdk/token-providers": "3.989.0",
        "@aws-sdk/types": "^3.973.1",
        "@aws-sdk/util-endpoints": "3.989.0",
        "@aws-sdk/util-user-agent-browser": "^3.972.3",
        "@aws-sdk/util-user-agent-node": "^3.972.7",
        "@smithy/config-resolver": "^4.4.6",
        "@smithy/core": "^3.23.0",
        "@smithy/eventstream-serde-browser": "^4.2.8",
        "@smithy/eventstream-serde-config-resolver": "^4.3.8",
        "@smithy/eventstream-serde-node": "^4.2.8",
        "@smithy/fetch-http-handler": "^5.3.9",
        "@smithy/hash-node": "^4.2.8",
        "@smithy/invalid-dependency": "^4.2.8",
        "@smithy/middleware-content-length": "^4.2.8",
        "@smithy/middleware-endpoint": "^4.4.14",
        "@smithy/middleware-retry": "^4.4.31",
        "@smithy/middleware-serde": "^4.2.9",
        "@smithy/middleware-stack": "^4.2.8",
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/node-http-handler": "^4.4.10",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/smithy-client": "^4.11.3",
        "@smithy/types": "^4.12.0",
        "@smithy/url-parser": "^4.2.8",
        "@smithy/util-base64": "^4.3.0",
        "@smithy/util-body-length-browser": "^4.2.0",
        "@smithy/util-body-length-node": "^4.2.1",
        "@smithy/util-defaults-mode-browser": "^4.3.30",
        "@smithy/util-defaults-mode-node": "^4.2.33",
        "@smithy/util-endpoints": "^3.2.8",
        "@smithy/util-middleware": "^4.2.8",
        "@smithy/util-retry": "^4.2.8",
        "@smithy/util-stream": "^4.5.12",
        "@smithy/util-utf8": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/client-sso": {
      "version": "3.989.0",
      "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.989.0.tgz",
      "integrity": "sha512-3sC+J1ru5VFXLgt9KZmXto0M7mnV5RkS6FNGwRMK3XrojSjHso9DLOWjbnXhbNv4motH8vu53L1HK2VC1+Nj5w==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-crypto/sha256-browser": "5.2.0",
        "@aws-crypto/sha256-js": "5.2.0",
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/middleware-host-header": "^3.972.3",
        "@aws-sdk/middleware-logger": "^3.972.3",
        "@aws-sdk/middleware-recursion-detection": "^3.972.3",
        "@aws-sdk/middleware-user-agent": "^3.972.9",
        "@aws-sdk/region-config-resolver": "^3.972.3",
        "@aws-sdk/types": "^3.973.1",
        "@aws-sdk/util-endpoints": "3.989.0",
        "@aws-sdk/util-user-agent-browser": "^3.972.3",
        "@aws-sdk/util-user-agent-node": "^3.972.7",
        "@smithy/config-resolver": "^4.4.6",
        "@smithy/core": "^3.23.0",
        "@smithy/fetch-http-handler": "^5.3.9",
        "@smithy/hash-node": "^4.2.8",
        "@smithy/invalid-dependency": "^4.2.8",
        "@smithy/middleware-content-length": "^4.2.8",
        "@smithy/middleware-endpoint": "^4.4.14",
        "@smithy/middleware-retry": "^4.4.31",
        "@smithy/middleware-serde": "^4.2.9",
        "@smithy/middleware-stack": "^4.2.8",
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/node-http-handler": "^4.4.10",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/smithy-client": "^4.11.3",
        "@smithy/types": "^4.12.0",
        "@smithy/url-parser": "^4.2.8",
        "@smithy/util-base64": "^4.3.0",
        "@smithy/util-body-length-browser": "^4.2.0",
        "@smithy/util-body-length-node": "^4.2.1",
        "@smithy/util-defaults-mode-browser": "^4.3.30",
        "@smithy/util-defaults-mode-node": "^4.2.33",
        "@smithy/util-endpoints": "^3.2.8",
        "@smithy/util-middleware": "^4.2.8",
        "@smithy/util-retry": "^4.2.8",
        "@smithy/util-utf8": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/core": {
      "version": "3.973.9",
      "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.9.tgz",
      "integrity": "sha512-cyUOfJSizn8da7XrBEFBf4UMI4A6JQNX6ZFcKtYmh/CrwfzsDcabv3k/z0bNwQ3pX5aeq5sg/8Bs/ASiL0bJaA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@aws-sdk/xml-builder": "^3.972.4",
        "@smithy/core": "^3.23.0",
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/signature-v4": "^5.3.8",
        "@smithy/smithy-client": "^4.11.3",
        "@smithy/types": "^4.12.0",
        "@smithy/util-base64": "^4.3.0",
        "@smithy/util-middleware": "^4.2.8",
        "@smithy/util-utf8": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/credential-provider-env": {
      "version": "3.972.7",
      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.7.tgz",
      "integrity": "sha512-r8kBtglvLjGxBT87l6Lqkh9fL8yJJ6O4CYQPjKlj3AkCuL4/4784x3rxxXWw9LTKXOo114VB6mjxAuy5pI7XIg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/credential-provider-http": {
      "version": "3.972.9",
      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.9.tgz",
      "integrity": "sha512-40caFblEg/TPrp9EpvyMxp4xlJ5TuTI+A8H6g8FhHn2hfH2PObFAPLF9d5AljK/G69E1YtTklkuQeAwPlV3w8Q==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/fetch-http-handler": "^5.3.9",
        "@smithy/node-http-handler": "^4.4.10",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/smithy-client": "^4.11.3",
        "@smithy/types": "^4.12.0",
        "@smithy/util-stream": "^4.5.12",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/credential-provider-ini": {
      "version": "3.972.7",
      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.7.tgz",
      "integrity": "sha512-zeYKrMwM5bCkHFho/x3+1OL0vcZQ0OhTR7k35tLq74+GP5ieV3juHXTZfa2LVE0Bg75cHIIerpX0gomVOhzo/w==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/credential-provider-env": "^3.972.7",
        "@aws-sdk/credential-provider-http": "^3.972.9",
        "@aws-sdk/credential-provider-login": "^3.972.7",
        "@aws-sdk/credential-provider-process": "^3.972.7",
        "@aws-sdk/credential-provider-sso": "^3.972.7",
        "@aws-sdk/credential-provider-web-identity": "^3.972.7",
        "@aws-sdk/nested-clients": "3.989.0",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/credential-provider-imds": "^4.2.8",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/shared-ini-file-loader": "^4.4.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/credential-provider-login": {
      "version": "3.972.7",
      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.7.tgz",
      "integrity": "sha512-Q103cLU6OjAllYjX7+V+PKQw654jjvZUkD+lbUUiFbqut6gR5zwl1DrelvJPM5hnzIty7BCaxaRB3KMuz3M/ug==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/nested-clients": "3.989.0",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/shared-ini-file-loader": "^4.4.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/credential-provider-node": {
      "version": "3.972.8",
      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.8.tgz",
      "integrity": "sha512-AaDVOT7iNJyLjc3j91VlucPZ4J8Bw+eu9sllRDugJqhHWYyR3Iyp2huBUW8A3+DfHoh70sxGkY92cThAicSzlQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/credential-provider-env": "^3.972.7",
        "@aws-sdk/credential-provider-http": "^3.972.9",
        "@aws-sdk/credential-provider-ini": "^3.972.7",
        "@aws-sdk/credential-provider-process": "^3.972.7",
        "@aws-sdk/credential-provider-sso": "^3.972.7",
        "@aws-sdk/credential-provider-web-identity": "^3.972.7",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/credential-provider-imds": "^4.2.8",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/shared-ini-file-loader": "^4.4.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/credential-provider-process": {
      "version": "3.972.7",
      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.7.tgz",
      "integrity": "sha512-hxMo1V3ujWWrQSONxQJAElnjredkRpB6p8SDjnvRq70IwYY38R/CZSys0IbhRPxdgWZ5j12yDRk2OXhxw4Gj3g==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/shared-ini-file-loader": "^4.4.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/credential-provider-sso": {
      "version": "3.972.7",
      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.7.tgz",
      "integrity": "sha512-ZGKBOHEj8Ap15jhG2XMncQmKLTqA++2DVU2eZfLu3T/pkwDyhCp5eZv5c/acFxbZcA/6mtxke+vzO/n+aeHs4A==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/client-sso": "3.989.0",
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/token-providers": "3.989.0",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/shared-ini-file-loader": "^4.4.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/credential-provider-web-identity": {
      "version": "3.972.7",
      "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.7.tgz",
      "integrity": "sha512-AbYupBIoSJoVMlbMqBhNvPhqj+CdGtzW7Uk4ZIMBm2br18pc3rkG1VaKVFV85H87QCvLHEnni1idJjaX1wOmIw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/nested-clients": "3.989.0",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/shared-ini-file-loader": "^4.4.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/eventstream-handler-node": {
      "version": "3.972.5",
      "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.5.tgz",
      "integrity": "sha512-xEmd3dnyn83K6t4AJxBJA63wpEoCD45ERFG0XMTViD2E/Ohls9TLxjOWPb1PAxR9/46cKy/TImez1GoqP6xVNQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@smithy/eventstream-codec": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/middleware-eventstream": {
      "version": "3.972.3",
      "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.3.tgz",
      "integrity": "sha512-pbvZ6Ye/Ks6BAZPa3RhsNjHrvxU9li25PMhSdDpbX0jzdpKpAkIR65gXSNKmA/REnSdEMWSD4vKUW+5eMFzB6w==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/middleware-host-header": {
      "version": "3.972.3",
      "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz",
      "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/middleware-logger": {
      "version": "3.972.3",
      "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz",
      "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/middleware-recursion-detection": {
      "version": "3.972.3",
      "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz",
      "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@aws/lambda-invoke-store": "^0.2.2",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/middleware-user-agent": {
      "version": "3.972.9",
      "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.9.tgz",
      "integrity": "sha512-1g1B7yf7KzessB0mKNiV9gAHEwbM662xgU+VE4LxyGe6kVGZ8LqYsngjhE+Stna09CJ7Pxkjr6Uq1OtbGwJJJg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/types": "^3.973.1",
        "@aws-sdk/util-endpoints": "3.989.0",
        "@smithy/core": "^3.23.0",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/middleware-websocket": {
      "version": "3.972.6",
      "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.6.tgz",
      "integrity": "sha512-1DedO6N3m8zQ/vG6twNiHtsdwBgk773VdavLEbB3NXeKZDlzSK1BTviqWwvJdKx5UnIy4kGGP6WWpCEFEt/bhQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@aws-sdk/util-format-url": "^3.972.3",
        "@smithy/eventstream-codec": "^4.2.8",
        "@smithy/eventstream-serde-browser": "^4.2.8",
        "@smithy/fetch-http-handler": "^5.3.9",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/signature-v4": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "@smithy/util-base64": "^4.3.0",
        "@smithy/util-hex-encoding": "^4.2.0",
        "@smithy/util-utf8": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">= 14.0.0"
      }
    },
    "node_modules/@aws-sdk/nested-clients": {
      "version": "3.989.0",
      "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.989.0.tgz",
      "integrity": "sha512-Dbk2HMPU3mb6RrSRzgf0WCaWSbgtZG258maCpuN2/ONcAQNpOTw99V5fU5CA1qVK6Vkm4Fwj2cnOnw7wbGVlOw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-crypto/sha256-browser": "5.2.0",
        "@aws-crypto/sha256-js": "5.2.0",
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/middleware-host-header": "^3.972.3",
        "@aws-sdk/middleware-logger": "^3.972.3",
        "@aws-sdk/middleware-recursion-detection": "^3.972.3",
        "@aws-sdk/middleware-user-agent": "^3.972.9",
        "@aws-sdk/region-config-resolver": "^3.972.3",
        "@aws-sdk/types": "^3.973.1",
        "@aws-sdk/util-endpoints": "3.989.0",
        "@aws-sdk/util-user-agent-browser": "^3.972.3",
        "@aws-sdk/util-user-agent-node": "^3.972.7",
        "@smithy/config-resolver": "^4.4.6",
        "@smithy/core": "^3.23.0",
        "@smithy/fetch-http-handler": "^5.3.9",
        "@smithy/hash-node": "^4.2.8",
        "@smithy/invalid-dependency": "^4.2.8",
        "@smithy/middleware-content-length": "^4.2.8",
        "@smithy/middleware-endpoint": "^4.4.14",
        "@smithy/middleware-retry": "^4.4.31",
        "@smithy/middleware-serde": "^4.2.9",
        "@smithy/middleware-stack": "^4.2.8",
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/node-http-handler": "^4.4.10",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/smithy-client": "^4.11.3",
        "@smithy/types": "^4.12.0",
        "@smithy/url-parser": "^4.2.8",
        "@smithy/util-base64": "^4.3.0",
        "@smithy/util-body-length-browser": "^4.2.0",
        "@smithy/util-body-length-node": "^4.2.1",
        "@smithy/util-defaults-mode-browser": "^4.3.30",
        "@smithy/util-defaults-mode-node": "^4.2.33",
        "@smithy/util-endpoints": "^3.2.8",
        "@smithy/util-middleware": "^4.2.8",
        "@smithy/util-retry": "^4.2.8",
        "@smithy/util-utf8": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/region-config-resolver": {
      "version": "3.972.3",
      "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz",
      "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@smithy/config-resolver": "^4.4.6",
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/token-providers": {
      "version": "3.989.0",
      "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.989.0.tgz",
      "integrity": "sha512-OdBByMv+OjOZoekrk4THPFpLuND5aIQbDHCGh3n2rvifAbm31+6e0OLhxSeCF1UMPm+nKq12bXYYEoCIx5SQBg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/core": "^3.973.9",
        "@aws-sdk/nested-clients": "3.989.0",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/shared-ini-file-loader": "^4.4.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/types": {
      "version": "3.973.1",
      "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz",
      "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/util-endpoints": {
      "version": "3.989.0",
      "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.989.0.tgz",
      "integrity": "sha512-eKmAOeQM4Qusq0jtcbZPiNWky8XaojByKC/n+THbJ8vJf7t4ys8LlcZ4PrBSHZISe9cC484mQsPVOQh6iySjqw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@smithy/types": "^4.12.0",
        "@smithy/url-parser": "^4.2.8",
        "@smithy/util-endpoints": "^3.2.8",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/util-format-url": {
      "version": "3.972.3",
      "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.3.tgz",
      "integrity": "sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@smithy/querystring-builder": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/util-locate-window": {
      "version": "3.965.4",
      "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz",
      "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws-sdk/util-user-agent-browser": {
      "version": "3.972.3",
      "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz",
      "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/types": "^3.973.1",
        "@smithy/types": "^4.12.0",
        "bowser": "^2.11.0",
        "tslib": "^2.6.2"
      }
    },
    "node_modules/@aws-sdk/util-user-agent-node": {
      "version": "3.972.7",
      "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.7.tgz",
      "integrity": "sha512-oyhv+FjrgHjP+F16cmsrJzNP4qaRJzkV1n9Lvv4uyh3kLqo3rIe9NSBSBa35f2TedczfG2dD+kaQhHBB47D6Og==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-sdk/middleware-user-agent": "^3.972.9",
        "@aws-sdk/types": "^3.973.1",
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      },
      "peerDependencies": {
        "aws-crt": ">=1.0.0"
      },
      "peerDependenciesMeta": {
        "aws-crt": {
          "optional": true
        }
      }
    },
    "node_modules/@aws-sdk/xml-builder": {
      "version": "3.972.4",
      "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
      "integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "fast-xml-parser": "5.3.4",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@aws/lambda-invoke-store": {
      "version": "0.2.3",
      "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
      "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==",
      "license": "Apache-2.0",
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@azure-rest/core-client": {
      "version": "2.5.1",
      "resolved": "https://registry.npmjs.org/@azure-rest/core-client/-/core-client-2.5.1.tgz",
      "integrity": "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A==",
      "license": "MIT",
      "dependencies": {
        "@azure/abort-controller": "^2.1.2",
        "@azure/core-auth": "^1.10.0",
        "@azure/core-rest-pipeline": "^1.22.0",
        "@azure/core-tracing": "^1.3.0",
        "@typespec/ts-http-runtime": "^0.3.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@azure/abort-controller": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
      "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
      "license": "MIT",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@azure/core-auth": {
      "version": "1.10.1",
      "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz",
      "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==",
      "license": "MIT",
      "dependencies": {
        "@azure/abort-controller": "^2.1.2",
        "@azure/core-util": "^1.13.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@azure/core-rest-pipeline": {
      "version": "1.22.2",
      "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz",
      "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
      "license": "MIT",
      "dependencies": {
        "@azure/abort-controller": "^2.1.2",
        "@azure/core-auth": "^1.10.0",
        "@azure/core-tracing": "^1.3.0",
        "@azure/core-util": "^1.13.0",
        "@azure/logger": "^1.3.0",
        "@typespec/ts-http-runtime": "^0.3.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@azure/core-tracing": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
      "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==",
      "license": "MIT",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@azure/core-util": {
      "version": "1.13.1",
      "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz",
      "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==",
      "license": "MIT",
      "dependencies": {
        "@azure/abort-controller": "^2.1.2",
        "@typespec/ts-http-runtime": "^0.3.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@azure/logger": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz",
      "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==",
      "license": "MIT",
      "dependencies": {
        "@typespec/ts-http-runtime": "^0.3.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@azure/openai": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/@azure/openai/-/openai-2.0.0.tgz",
      "integrity": "sha512-zSNhwarYbqg3P048uKMjEjbge41OnAgmiiE1elCHVsuCCXRyz2BXnHMJkW6WR6ZKQy5NHswJNUNSWsuqancqFA==",
      "license": "MIT",
      "dependencies": {
        "@azure-rest/core-client": "^2.2.0",
        "tslib": "^2.6.3"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@babel/helper-string-parser": {
      "version": "7.27.1",
      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-validator-identifier": {
      "version": "7.28.5",
      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/parser": {
      "version": "7.29.0",
      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
      "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/types": "^7.29.0"
      },
      "bin": {
        "parser": "bin/babel-parser.js"
      },
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/@babel/runtime": {
      "version": "7.28.6",
      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
      "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/types": {
      "version": "7.29.0",
      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-string-parser": "^7.27.1",
        "@babel/helper-validator-identifier": "^7.28.5"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@bcoe/v8-coverage": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
      "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/aix-ppc64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
      "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "aix"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
      "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
      "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/android-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
      "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
      "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/darwin-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
      "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
      "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/freebsd-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
      "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
      "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
      "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ia32": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
      "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-loong64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
      "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-mips64el": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
      "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
      "cpu": [
        "mips64el"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-ppc64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
      "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-riscv64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
      "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-s390x": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
      "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
      "cpu": [
        "s390x"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/linux-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
      "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
      "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/netbsd-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
      "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "netbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
      "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openbsd-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
      "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/openharmony-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
      "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openharmony"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/sunos-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
      "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "sunos"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-arm64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
      "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-ia32": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
      "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@esbuild/win32-x64": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
      "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@fastify/ajv-compiler": {
      "version": "4.0.5",
      "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
      "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "ajv": "^8.12.0",
        "ajv-formats": "^3.0.1",
        "fast-uri": "^3.0.0"
      }
    },
    "node_modules/@fastify/cors": {
      "version": "10.1.0",
      "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz",
      "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "fastify-plugin": "^5.0.0",
        "mnemonist": "0.40.0"
      }
    },
    "node_modules/@fastify/error": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
      "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT"
    },
    "node_modules/@fastify/fast-json-stringify-compiler": {
      "version": "5.0.3",
      "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz",
      "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "fast-json-stringify": "^6.0.0"
      }
    },
    "node_modules/@fastify/forwarded": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
      "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT"
    },
    "node_modules/@fastify/merge-json-schemas": {
      "version": "0.2.1",
      "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
      "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "dequal": "^2.0.3"
      }
    },
    "node_modules/@fastify/proxy-addr": {
      "version": "5.1.0",
      "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
      "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "@fastify/forwarded": "^3.0.0",
        "ipaddr.js": "^2.1.0"
      }
    },
    "node_modules/@fastify/websocket": {
      "version": "11.2.0",
      "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
      "integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "duplexify": "^4.1.3",
        "fastify-plugin": "^5.0.0",
        "ws": "^8.16.0"
      }
    },
    "node_modules/@google-cloud/vertexai": {
      "version": "1.10.0",
      "resolved": "https://registry.npmjs.org/@google-cloud/vertexai/-/vertexai-1.10.0.tgz",
      "integrity": "sha512-HqYqoivNtkq59po8m7KI0n+lWKdz4kabENncYQXZCX/hBWJfXtKAfR/2nUQsP+TwSfHKoA7zDL2RrJYIv/j3VQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "google-auth-library": "^9.1.0"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@jridgewell/resolve-uri": {
      "version": "3.1.2",
      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/@jridgewell/sourcemap-codec": {
      "version": "1.5.5",
      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@jridgewell/trace-mapping": {
      "version": "0.3.31",
      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/resolve-uri": "^3.1.0",
        "@jridgewell/sourcemap-codec": "^1.4.14"
      }
    },
    "node_modules/@pinojs/redact": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
      "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
      "license": "MIT"
    },
    "node_modules/@prisma/client": {
      "version": "5.22.0",
      "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
      "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
      "hasInstallScript": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=16.13"
      },
      "peerDependencies": {
        "prisma": "*"
      },
      "peerDependenciesMeta": {
        "prisma": {
          "optional": true
        }
      }
    },
    "node_modules/@prisma/debug": {
      "version": "5.22.0",
      "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
      "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
      "devOptional": true,
      "license": "Apache-2.0"
    },
    "node_modules/@prisma/engines": {
      "version": "5.22.0",
      "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
      "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
      "devOptional": true,
      "hasInstallScript": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@prisma/debug": "5.22.0",
        "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
        "@prisma/fetch-engine": "5.22.0",
        "@prisma/get-platform": "5.22.0"
      }
    },
    "node_modules/@prisma/engines-version": {
      "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
      "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
      "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
      "devOptional": true,
      "license": "Apache-2.0"
    },
    "node_modules/@prisma/fetch-engine": {
      "version": "5.22.0",
      "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
      "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
      "devOptional": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@prisma/debug": "5.22.0",
        "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
        "@prisma/get-platform": "5.22.0"
      }
    },
    "node_modules/@prisma/get-platform": {
      "version": "5.22.0",
      "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
      "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
      "devOptional": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@prisma/debug": "5.22.0"
      }
    },
    "node_modules/@rollup/rollup-android-arm-eabi": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
      "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@rollup/rollup-android-arm64": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
      "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@rollup/rollup-darwin-arm64": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
      "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@rollup/rollup-darwin-x64": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
      "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@rollup/rollup-freebsd-arm64": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
      "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ]
    },
    "node_modules/@rollup/rollup-freebsd-x64": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
      "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
      "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
      "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm64-gnu": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
      "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-arm64-musl": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
      "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-loong64-gnu": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
      "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-loong64-musl": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
      "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
      "cpu": [
        "loong64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
      "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-ppc64-musl": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
      "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
      "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-riscv64-musl": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
      "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-s390x-gnu": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
      "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
      "cpu": [
        "s390x"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-x64-gnu": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
      "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-linux-x64-musl": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
      "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@rollup/rollup-openbsd-x64": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
      "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openbsd"
      ]
    },
    "node_modules/@rollup/rollup-openharmony-arm64": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
      "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "openharmony"
      ]
    },
    "node_modules/@rollup/rollup-win32-arm64-msvc": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
      "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-ia32-msvc": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
      "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-x64-gnu": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
      "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@rollup/rollup-win32-x64-msvc": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
      "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@smithy/abort-controller": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz",
      "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/config-resolver": {
      "version": "4.4.6",
      "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz",
      "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/types": "^4.12.0",
        "@smithy/util-config-provider": "^4.2.0",
        "@smithy/util-endpoints": "^3.2.8",
        "@smithy/util-middleware": "^4.2.8",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/core": {
      "version": "3.23.0",
      "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.0.tgz",
      "integrity": "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/middleware-serde": "^4.2.9",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "@smithy/util-base64": "^4.3.0",
        "@smithy/util-body-length-browser": "^4.2.0",
        "@smithy/util-middleware": "^4.2.8",
        "@smithy/util-stream": "^4.5.12",
        "@smithy/util-utf8": "^4.2.0",
        "@smithy/uuid": "^1.1.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/credential-provider-imds": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz",
      "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "@smithy/url-parser": "^4.2.8",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/eventstream-codec": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz",
      "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@aws-crypto/crc32": "5.2.0",
        "@smithy/types": "^4.12.0",
        "@smithy/util-hex-encoding": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/eventstream-serde-browser": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz",
      "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/eventstream-serde-universal": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/eventstream-serde-config-resolver": {
      "version": "4.3.8",
      "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz",
      "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/eventstream-serde-node": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz",
      "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/eventstream-serde-universal": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/eventstream-serde-universal": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz",
      "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/eventstream-codec": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/fetch-http-handler": {
      "version": "5.3.9",
      "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz",
      "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/querystring-builder": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "@smithy/util-base64": "^4.3.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/hash-node": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz",
      "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "@smithy/util-buffer-from": "^4.2.0",
        "@smithy/util-utf8": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/invalid-dependency": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz",
      "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/is-array-buffer": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz",
      "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/middleware-content-length": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz",
      "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/middleware-endpoint": {
      "version": "4.4.14",
      "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.14.tgz",
      "integrity": "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/core": "^3.23.0",
        "@smithy/middleware-serde": "^4.2.9",
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/shared-ini-file-loader": "^4.4.3",
        "@smithy/types": "^4.12.0",
        "@smithy/url-parser": "^4.2.8",
        "@smithy/util-middleware": "^4.2.8",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/middleware-retry": {
      "version": "4.4.31",
      "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.31.tgz",
      "integrity": "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/service-error-classification": "^4.2.8",
        "@smithy/smithy-client": "^4.11.3",
        "@smithy/types": "^4.12.0",
        "@smithy/util-middleware": "^4.2.8",
        "@smithy/util-retry": "^4.2.8",
        "@smithy/uuid": "^1.1.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/middleware-serde": {
      "version": "4.2.9",
      "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz",
      "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/middleware-stack": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz",
      "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/node-config-provider": {
      "version": "4.3.8",
      "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz",
      "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/property-provider": "^4.2.8",
        "@smithy/shared-ini-file-loader": "^4.4.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/node-http-handler": {
      "version": "4.4.10",
      "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz",
      "integrity": "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/abort-controller": "^4.2.8",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/querystring-builder": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/property-provider": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz",
      "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/protocol-http": {
      "version": "5.3.8",
      "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz",
      "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/querystring-builder": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz",
      "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "@smithy/util-uri-escape": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/querystring-parser": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz",
      "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/service-error-classification": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz",
      "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/shared-ini-file-loader": {
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz",
      "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/signature-v4": {
      "version": "5.3.8",
      "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz",
      "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/is-array-buffer": "^4.2.0",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "@smithy/util-hex-encoding": "^4.2.0",
        "@smithy/util-middleware": "^4.2.8",
        "@smithy/util-uri-escape": "^4.2.0",
        "@smithy/util-utf8": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/smithy-client": {
      "version": "4.11.3",
      "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.3.tgz",
      "integrity": "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/core": "^3.23.0",
        "@smithy/middleware-endpoint": "^4.4.14",
        "@smithy/middleware-stack": "^4.2.8",
        "@smithy/protocol-http": "^5.3.8",
        "@smithy/types": "^4.12.0",
        "@smithy/util-stream": "^4.5.12",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/types": {
      "version": "4.12.0",
      "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
      "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/url-parser": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz",
      "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/querystring-parser": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-base64": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz",
      "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/util-buffer-from": "^4.2.0",
        "@smithy/util-utf8": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-body-length-browser": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz",
      "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-body-length-node": {
      "version": "4.2.1",
      "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz",
      "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-buffer-from": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz",
      "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/is-array-buffer": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-config-provider": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz",
      "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-defaults-mode-browser": {
      "version": "4.3.30",
      "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.30.tgz",
      "integrity": "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/property-provider": "^4.2.8",
        "@smithy/smithy-client": "^4.11.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-defaults-mode-node": {
      "version": "4.2.33",
      "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.33.tgz",
      "integrity": "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/config-resolver": "^4.4.6",
        "@smithy/credential-provider-imds": "^4.2.8",
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/property-provider": "^4.2.8",
        "@smithy/smithy-client": "^4.11.3",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-endpoints": {
      "version": "3.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz",
      "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/node-config-provider": "^4.3.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-hex-encoding": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz",
      "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-middleware": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz",
      "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-retry": {
      "version": "4.2.8",
      "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz",
      "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/service-error-classification": "^4.2.8",
        "@smithy/types": "^4.12.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-stream": {
      "version": "4.5.12",
      "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.12.tgz",
      "integrity": "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/fetch-http-handler": "^5.3.9",
        "@smithy/node-http-handler": "^4.4.10",
        "@smithy/types": "^4.12.0",
        "@smithy/util-base64": "^4.3.0",
        "@smithy/util-buffer-from": "^4.2.0",
        "@smithy/util-hex-encoding": "^4.2.0",
        "@smithy/util-utf8": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-uri-escape": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz",
      "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/util-utf8": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz",
      "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==",
      "license": "Apache-2.0",
      "dependencies": {
        "@smithy/util-buffer-from": "^4.2.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@smithy/uuid": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz",
      "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=18.0.0"
      }
    },
    "node_modules/@standard-schema/spec": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/chai": {
      "version": "5.2.3",
      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@types/deep-eql": "*",
        "assertion-error": "^2.0.1"
      }
    },
    "node_modules/@types/deep-eql": {
      "version": "4.0.2",
      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/estree": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/node": {
      "version": "22.19.11",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
      "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "undici-types": "~6.21.0"
      }
    },
    "node_modules/@typespec/ts-http-runtime": {
      "version": "0.3.3",
      "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz",
      "integrity": "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==",
      "license": "MIT",
      "dependencies": {
        "http-proxy-agent": "^7.0.0",
        "https-proxy-agent": "^7.0.0",
        "tslib": "^2.6.2"
      },
      "engines": {
        "node": ">=20.0.0"
      }
    },
    "node_modules/@vitest/coverage-v8": {
      "version": "4.0.18",
      "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
      "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@bcoe/v8-coverage": "^1.0.2",
        "@vitest/utils": "4.0.18",
        "ast-v8-to-istanbul": "^0.3.10",
        "istanbul-lib-coverage": "^3.2.2",
        "istanbul-lib-report": "^3.0.1",
        "istanbul-reports": "^3.2.0",
        "magicast": "^0.5.1",
        "obug": "^2.1.1",
        "std-env": "^3.10.0",
        "tinyrainbow": "^3.0.3"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      },
      "peerDependencies": {
        "@vitest/browser": "4.0.18",
        "vitest": "4.0.18"
      },
      "peerDependenciesMeta": {
        "@vitest/browser": {
          "optional": true
        }
      }
    },
    "node_modules/@vitest/expect": {
      "version": "4.0.18",
      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
      "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@standard-schema/spec": "^1.0.0",
        "@types/chai": "^5.2.2",
        "@vitest/spy": "4.0.18",
        "@vitest/utils": "4.0.18",
        "chai": "^6.2.1",
        "tinyrainbow": "^3.0.3"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/mocker": {
      "version": "4.0.18",
      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
      "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/spy": "4.0.18",
        "estree-walker": "^3.0.3",
        "magic-string": "^0.30.21"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      },
      "peerDependencies": {
        "msw": "^2.4.9",
        "vite": "^6.0.0 || ^7.0.0-0"
      },
      "peerDependenciesMeta": {
        "msw": {
          "optional": true
        },
        "vite": {
          "optional": true
        }
      }
    },
    "node_modules/@vitest/pretty-format": {
      "version": "4.0.18",
      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
      "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "tinyrainbow": "^3.0.3"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/runner": {
      "version": "4.0.18",
      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
      "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/utils": "4.0.18",
        "pathe": "^2.0.3"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/snapshot": {
      "version": "4.0.18",
      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
      "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/pretty-format": "4.0.18",
        "magic-string": "^0.30.21",
        "pathe": "^2.0.3"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/spy": {
      "version": "4.0.18",
      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
      "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
      "dev": true,
      "license": "MIT",
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/@vitest/utils": {
      "version": "4.0.18",
      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
      "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/pretty-format": "4.0.18",
        "tinyrainbow": "^3.0.3"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      }
    },
    "node_modules/abstract-logging": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
      "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
      "license": "MIT"
    },
    "node_modules/agent-base": {
      "version": "7.1.4",
      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
      "license": "MIT",
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/ajv": {
      "version": "8.17.1",
      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
      "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
      "license": "MIT",
      "dependencies": {
        "fast-deep-equal": "^3.1.3",
        "fast-uri": "^3.0.1",
        "json-schema-traverse": "^1.0.0",
        "require-from-string": "^2.0.2"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/epoberezkin"
      }
    },
    "node_modules/ajv-formats": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
      "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
      "license": "MIT",
      "dependencies": {
        "ajv": "^8.0.0"
      },
      "peerDependencies": {
        "ajv": "^8.0.0"
      },
      "peerDependenciesMeta": {
        "ajv": {
          "optional": true
        }
      }
    },
    "node_modules/assertion-error": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/ast-v8-to-istanbul": {
      "version": "0.3.11",
      "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
      "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/trace-mapping": "^0.3.31",
        "estree-walker": "^3.0.3",
        "js-tokens": "^10.0.0"
      }
    },
    "node_modules/atomic-sleep": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
      "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
      "license": "MIT",
      "engines": {
        "node": ">=8.0.0"
      }
    },
    "node_modules/avvio": {
      "version": "9.1.0",
      "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz",
      "integrity": "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==",
      "license": "MIT",
      "dependencies": {
        "@fastify/error": "^4.0.0",
        "fastq": "^1.17.1"
      }
    },
    "node_modules/base64-js": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/bignumber.js": {
      "version": "9.3.1",
      "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
      "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
      "license": "MIT",
      "engines": {
        "node": "*"
      }
    },
    "node_modules/bowser": {
      "version": "2.14.1",
      "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
      "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
      "license": "MIT"
    },
    "node_modules/buffer-equal-constant-time": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
      "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
      "license": "BSD-3-Clause"
    },
    "node_modules/chai": {
      "version": "6.2.2",
      "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
      "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/colorette": {
      "version": "2.0.20",
      "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
      "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/complex.js": {
      "version": "2.4.3",
      "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz",
      "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==",
      "license": "MIT",
      "engines": {
        "node": "*"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/rawify"
      }
    },
    "node_modules/cookie": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
      "license": "MIT",
      "engines": {
        "node": ">=18"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/express"
      }
    },
    "node_modules/dateformat": {
      "version": "4.6.3",
      "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
      "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "*"
      }
    },
    "node_modules/debug": {
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
      "license": "MIT",
      "dependencies": {
        "ms": "^2.1.3"
      },
      "engines": {
        "node": ">=6.0"
      },
      "peerDependenciesMeta": {
        "supports-color": {
          "optional": true
        }
      }
    },
    "node_modules/decimal.js": {
      "version": "10.6.0",
      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
      "license": "MIT"
    },
    "node_modules/dequal": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/dotenv": {
      "version": "17.3.1",
      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
      "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://dotenvx.com"
      }
    },
    "node_modules/duplexify": {
      "version": "4.1.3",
      "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
      "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
      "license": "MIT",
      "dependencies": {
        "end-of-stream": "^1.4.1",
        "inherits": "^2.0.3",
        "readable-stream": "^3.1.1",
        "stream-shift": "^1.0.2"
      }
    },
    "node_modules/ecdsa-sig-formatter": {
      "version": "1.0.11",
      "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
      "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "safe-buffer": "^5.0.1"
      }
    },
    "node_modules/end-of-stream": {
      "version": "1.4.5",
      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
      "license": "MIT",
      "dependencies": {
        "once": "^1.4.0"
      }
    },
    "node_modules/es-module-lexer": {
      "version": "1.7.0",
      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
      "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/esbuild": {
      "version": "0.27.3",
      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
      "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
      "dev": true,
      "hasInstallScript": true,
      "license": "MIT",
      "bin": {
        "esbuild": "bin/esbuild"
      },
      "engines": {
        "node": ">=18"
      },
      "optionalDependencies": {
        "@esbuild/aix-ppc64": "0.27.3",
        "@esbuild/android-arm": "0.27.3",
        "@esbuild/android-arm64": "0.27.3",
        "@esbuild/android-x64": "0.27.3",
        "@esbuild/darwin-arm64": "0.27.3",
        "@esbuild/darwin-x64": "0.27.3",
        "@esbuild/freebsd-arm64": "0.27.3",
        "@esbuild/freebsd-x64": "0.27.3",
        "@esbuild/linux-arm": "0.27.3",
        "@esbuild/linux-arm64": "0.27.3",
        "@esbuild/linux-ia32": "0.27.3",
        "@esbuild/linux-loong64": "0.27.3",
        "@esbuild/linux-mips64el": "0.27.3",
        "@esbuild/linux-ppc64": "0.27.3",
        "@esbuild/linux-riscv64": "0.27.3",
        "@esbuild/linux-s390x": "0.27.3",
        "@esbuild/linux-x64": "0.27.3",
        "@esbuild/netbsd-arm64": "0.27.3",
        "@esbuild/netbsd-x64": "0.27.3",
        "@esbuild/openbsd-arm64": "0.27.3",
        "@esbuild/openbsd-x64": "0.27.3",
        "@esbuild/openharmony-arm64": "0.27.3",
        "@esbuild/sunos-x64": "0.27.3",
        "@esbuild/win32-arm64": "0.27.3",
        "@esbuild/win32-ia32": "0.27.3",
        "@esbuild/win32-x64": "0.27.3"
      }
    },
    "node_modules/escape-latex": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
      "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==",
      "license": "MIT"
    },
    "node_modules/estree-walker": {
      "version": "3.0.3",
      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@types/estree": "^1.0.0"
      }
    },
    "node_modules/expect-type": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
      "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=12.0.0"
      }
    },
    "node_modules/extend": {
      "version": "3.0.2",
      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
      "license": "MIT"
    },
    "node_modules/fast-copy": {
      "version": "4.0.2",
      "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
      "integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fast-decode-uri-component": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
      "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
      "license": "MIT"
    },
    "node_modules/fast-deep-equal": {
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
      "license": "MIT"
    },
    "node_modules/fast-json-stringify": {
      "version": "6.3.0",
      "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz",
      "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "@fastify/merge-json-schemas": "^0.2.0",
        "ajv": "^8.12.0",
        "ajv-formats": "^3.0.1",
        "fast-uri": "^3.0.0",
        "json-schema-ref-resolver": "^3.0.0",
        "rfdc": "^1.2.0"
      }
    },
    "node_modules/fast-querystring": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
      "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
      "license": "MIT",
      "dependencies": {
        "fast-decode-uri-component": "^1.0.1"
      }
    },
    "node_modules/fast-safe-stringify": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
      "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fast-uri": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
      "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "BSD-3-Clause"
    },
    "node_modules/fast-xml-parser": {
      "version": "5.3.4",
      "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
      "integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/NaturalIntelligence"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "strnum": "^2.1.0"
      },
      "bin": {
        "fxparser": "src/cli/cli.js"
      }
    },
    "node_modules/fastify": {
      "version": "5.7.4",
      "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
      "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "@fastify/ajv-compiler": "^4.0.5",
        "@fastify/error": "^4.0.0",
        "@fastify/fast-json-stringify-compiler": "^5.0.0",
        "@fastify/proxy-addr": "^5.0.0",
        "abstract-logging": "^2.0.1",
        "avvio": "^9.0.0",
        "fast-json-stringify": "^6.0.0",
        "find-my-way": "^9.0.0",
        "light-my-request": "^6.0.0",
        "pino": "^10.1.0",
        "process-warning": "^5.0.0",
        "rfdc": "^1.3.1",
        "secure-json-parse": "^4.0.0",
        "semver": "^7.6.0",
        "toad-cache": "^3.7.0"
      }
    },
    "node_modules/fastify-plugin": {
      "version": "5.1.0",
      "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
      "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT"
    },
    "node_modules/fastq": {
      "version": "1.20.1",
      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
      "license": "ISC",
      "dependencies": {
        "reusify": "^1.0.4"
      }
    },
    "node_modules/fdir": {
      "version": "6.5.0",
      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12.0.0"
      },
      "peerDependencies": {
        "picomatch": "^3 || ^4"
      },
      "peerDependenciesMeta": {
        "picomatch": {
          "optional": true
        }
      }
    },
    "node_modules/find-my-way": {
      "version": "9.4.0",
      "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz",
      "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==",
      "license": "MIT",
      "dependencies": {
        "fast-deep-equal": "^3.1.3",
        "fast-querystring": "^1.0.0",
        "safe-regex2": "^5.0.0"
      },
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/fraction.js": {
      "version": "5.3.4",
      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
      "license": "MIT",
      "engines": {
        "node": "*"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/rawify"
      }
    },
    "node_modules/fsevents": {
      "version": "2.3.3",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
      "dev": true,
      "hasInstallScript": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
      }
    },
    "node_modules/gaxios": {
      "version": "6.7.1",
      "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
      "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
      "license": "Apache-2.0",
      "dependencies": {
        "extend": "^3.0.2",
        "https-proxy-agent": "^7.0.1",
        "is-stream": "^2.0.0",
        "node-fetch": "^2.6.9",
        "uuid": "^9.0.1"
      },
      "engines": {
        "node": ">=14"
      }
    },
    "node_modules/gcp-metadata": {
      "version": "6.1.1",
      "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
      "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
      "license": "Apache-2.0",
      "dependencies": {
        "gaxios": "^6.1.1",
        "google-logging-utils": "^0.0.2",
        "json-bigint": "^1.0.0"
      },
      "engines": {
        "node": ">=14"
      }
    },
    "node_modules/get-tsconfig": {
      "version": "4.13.6",
      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
      "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "resolve-pkg-maps": "^1.0.0"
      },
      "funding": {
        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
      }
    },
    "node_modules/google-auth-library": {
      "version": "9.15.1",
      "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
      "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
      "license": "Apache-2.0",
      "dependencies": {
        "base64-js": "^1.3.0",
        "ecdsa-sig-formatter": "^1.0.11",
        "gaxios": "^6.1.1",
        "gcp-metadata": "^6.1.0",
        "gtoken": "^7.0.0",
        "jws": "^4.0.0"
      },
      "engines": {
        "node": ">=14"
      }
    },
    "node_modules/google-logging-utils": {
      "version": "0.0.2",
      "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
      "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
      "license": "Apache-2.0",
      "engines": {
        "node": ">=14"
      }
    },
    "node_modules/gtoken": {
      "version": "7.1.0",
      "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
      "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
      "license": "MIT",
      "dependencies": {
        "gaxios": "^6.0.0",
        "jws": "^4.0.0"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/has-flag": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/help-me": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
      "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/html-escaper": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/http-proxy-agent": {
      "version": "7.0.2",
      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
      "license": "MIT",
      "dependencies": {
        "agent-base": "^7.1.0",
        "debug": "^4.3.4"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/https-proxy-agent": {
      "version": "7.0.6",
      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
      "license": "MIT",
      "dependencies": {
        "agent-base": "^7.1.2",
        "debug": "4"
      },
      "engines": {
        "node": ">= 14"
      }
    },
    "node_modules/inherits": {
      "version": "2.0.4",
      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
      "license": "ISC"
    },
    "node_modules/ipaddr.js": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
      "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
      "license": "MIT",
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/is-stream": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
      "license": "MIT",
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/istanbul-lib-coverage": {
      "version": "3.2.2",
      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
      "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
      "dev": true,
      "license": "BSD-3-Clause",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/istanbul-lib-report": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
      "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
      "dev": true,
      "license": "BSD-3-Clause",
      "dependencies": {
        "istanbul-lib-coverage": "^3.0.0",
        "make-dir": "^4.0.0",
        "supports-color": "^7.1.0"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/istanbul-reports": {
      "version": "3.2.0",
      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
      "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
      "dev": true,
      "license": "BSD-3-Clause",
      "dependencies": {
        "html-escaper": "^2.0.0",
        "istanbul-lib-report": "^3.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/javascript-natural-sort": {
      "version": "0.7.1",
      "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
      "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==",
      "license": "MIT"
    },
    "node_modules/joycon": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
      "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/js-tokens": {
      "version": "10.0.0",
      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
      "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/json-bigint": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
      "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
      "license": "MIT",
      "dependencies": {
        "bignumber.js": "^9.0.0"
      }
    },
    "node_modules/json-schema-ref-resolver": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
      "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "dequal": "^2.0.3"
      }
    },
    "node_modules/json-schema-traverse": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
      "license": "MIT"
    },
    "node_modules/jwa": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
      "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
      "license": "MIT",
      "dependencies": {
        "buffer-equal-constant-time": "^1.0.1",
        "ecdsa-sig-formatter": "1.0.11",
        "safe-buffer": "^5.0.1"
      }
    },
    "node_modules/jws": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
      "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
      "license": "MIT",
      "dependencies": {
        "jwa": "^2.0.1",
        "safe-buffer": "^5.0.1"
      }
    },
    "node_modules/light-my-request": {
      "version": "6.6.0",
      "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
      "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "BSD-3-Clause",
      "dependencies": {
        "cookie": "^1.0.1",
        "process-warning": "^4.0.0",
        "set-cookie-parser": "^2.6.0"
      }
    },
    "node_modules/light-my-request/node_modules/process-warning": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
      "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT"
    },
    "node_modules/magic-string": {
      "version": "0.30.21",
      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/sourcemap-codec": "^1.5.5"
      }
    },
    "node_modules/magicast": {
      "version": "0.5.2",
      "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
      "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/parser": "^7.29.0",
        "@babel/types": "^7.29.0",
        "source-map-js": "^1.2.1"
      }
    },
    "node_modules/make-dir": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
      "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "semver": "^7.5.3"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/mathjs": {
      "version": "15.1.1",
      "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.1.1.tgz",
      "integrity": "sha512-rM668DTtpSzMVoh/cKAllyQVEbBApM5g//IMGD8vD7YlrIz9ITRr3SrdhjaDxcBNTdyETWwPebj2unZyHD7ZdA==",
      "license": "Apache-2.0",
      "dependencies": {
        "@babel/runtime": "^7.26.10",
        "complex.js": "^2.2.5",
        "decimal.js": "^10.4.3",
        "escape-latex": "^1.2.0",
        "fraction.js": "^5.2.1",
        "javascript-natural-sort": "^0.7.1",
        "seedrandom": "^3.0.5",
        "tiny-emitter": "^2.1.0",
        "typed-function": "^4.2.1"
      },
      "bin": {
        "mathjs": "bin/cli.js"
      },
      "engines": {
        "node": ">= 18"
      }
    },
    "node_modules/minimist": {
      "version": "1.2.8",
      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
      "dev": true,
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/mnemonist": {
      "version": "0.40.0",
      "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz",
      "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==",
      "license": "MIT",
      "dependencies": {
        "obliterator": "^2.0.4"
      }
    },
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
      "license": "MIT"
    },
    "node_modules/nanoid": {
      "version": "5.1.6",
      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
      "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "bin": {
        "nanoid": "bin/nanoid.js"
      },
      "engines": {
        "node": "^18 || >=20"
      }
    },
    "node_modules/node-fetch": {
      "version": "2.7.0",
      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
      "license": "MIT",
      "dependencies": {
        "whatwg-url": "^5.0.0"
      },
      "engines": {
        "node": "4.x || >=6.0.0"
      },
      "peerDependencies": {
        "encoding": "^0.1.0"
      },
      "peerDependenciesMeta": {
        "encoding": {
          "optional": true
        }
      }
    },
    "node_modules/obliterator": {
      "version": "2.0.5",
      "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
      "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
      "license": "MIT"
    },
    "node_modules/obug": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
      "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
      "dev": true,
      "funding": [
        "https://github.com/sponsors/sxzz",
        "https://opencollective.com/debug"
      ],
      "license": "MIT"
    },
    "node_modules/on-exit-leak-free": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
      "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
      "license": "MIT",
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/once": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
      "license": "ISC",
      "dependencies": {
        "wrappy": "1"
      }
    },
    "node_modules/openai": {
      "version": "6.22.0",
      "resolved": "https://registry.npmjs.org/openai/-/openai-6.22.0.tgz",
      "integrity": "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw==",
      "license": "Apache-2.0",
      "bin": {
        "openai": "bin/cli"
      },
      "peerDependencies": {
        "ws": "^8.18.0",
        "zod": "^3.25 || ^4.0"
      },
      "peerDependenciesMeta": {
        "ws": {
          "optional": true
        },
        "zod": {
          "optional": true
        }
      }
    },
    "node_modules/pathe": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/picomatch": {
      "version": "4.0.3",
      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/pino": {
      "version": "10.3.1",
      "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
      "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
      "license": "MIT",
      "dependencies": {
        "@pinojs/redact": "^0.4.0",
        "atomic-sleep": "^1.0.0",
        "on-exit-leak-free": "^2.1.0",
        "pino-abstract-transport": "^3.0.0",
        "pino-std-serializers": "^7.0.0",
        "process-warning": "^5.0.0",
        "quick-format-unescaped": "^4.0.3",
        "real-require": "^0.2.0",
        "safe-stable-stringify": "^2.3.1",
        "sonic-boom": "^4.0.1",
        "thread-stream": "^4.0.0"
      },
      "bin": {
        "pino": "bin.js"
      }
    },
    "node_modules/pino-abstract-transport": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
      "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
      "license": "MIT",
      "dependencies": {
        "split2": "^4.0.0"
      }
    },
    "node_modules/pino-pretty": {
      "version": "13.1.3",
      "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
      "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "colorette": "^2.0.7",
        "dateformat": "^4.6.3",
        "fast-copy": "^4.0.0",
        "fast-safe-stringify": "^2.1.1",
        "help-me": "^5.0.0",
        "joycon": "^3.1.1",
        "minimist": "^1.2.6",
        "on-exit-leak-free": "^2.1.0",
        "pino-abstract-transport": "^3.0.0",
        "pump": "^3.0.0",
        "secure-json-parse": "^4.0.0",
        "sonic-boom": "^4.0.1",
        "strip-json-comments": "^5.0.2"
      },
      "bin": {
        "pino-pretty": "bin.js"
      }
    },
    "node_modules/pino-std-serializers": {
      "version": "7.1.0",
      "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
      "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
      "license": "MIT"
    },
    "node_modules/postcss": {
      "version": "8.5.6",
      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
      "dev": true,
      "funding": [
        {
          "type": "opencollective",
          "url": "https://opencollective.com/postcss/"
        },
        {
          "type": "tidelift",
          "url": "https://tidelift.com/funding/github/npm/postcss"
        },
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "nanoid": "^3.3.11",
        "picocolors": "^1.1.1",
        "source-map-js": "^1.2.1"
      },
      "engines": {
        "node": "^10 || ^12 || >=14"
      }
    },
    "node_modules/postcss/node_modules/nanoid": {
      "version": "3.3.11",
      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
      "dev": true,
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "bin": {
        "nanoid": "bin/nanoid.cjs"
      },
      "engines": {
        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
      }
    },
    "node_modules/prisma": {
      "version": "5.22.0",
      "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
      "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
      "devOptional": true,
      "hasInstallScript": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@prisma/engines": "5.22.0"
      },
      "bin": {
        "prisma": "build/index.js"
      },
      "engines": {
        "node": ">=16.13"
      },
      "optionalDependencies": {
        "fsevents": "2.3.3"
      }
    },
    "node_modules/process-warning": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
      "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT"
    },
    "node_modules/pump": {
      "version": "3.0.3",
      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
      "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "end-of-stream": "^1.1.0",
        "once": "^1.3.1"
      }
    },
    "node_modules/quick-format-unescaped": {
      "version": "4.0.4",
      "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
      "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
      "license": "MIT"
    },
    "node_modules/readable-stream": {
      "version": "3.6.2",
      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
      "license": "MIT",
      "dependencies": {
        "inherits": "^2.0.3",
        "string_decoder": "^1.1.1",
        "util-deprecate": "^1.0.1"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/real-require": {
      "version": "0.2.0",
      "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
      "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
      "license": "MIT",
      "engines": {
        "node": ">= 12.13.0"
      }
    },
    "node_modules/require-from-string": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/resolve-pkg-maps": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
      "dev": true,
      "license": "MIT",
      "funding": {
        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
      }
    },
    "node_modules/ret": {
      "version": "0.5.0",
      "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
      "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
      "license": "MIT",
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/reusify": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
      "license": "MIT",
      "engines": {
        "iojs": ">=1.0.0",
        "node": ">=0.10.0"
      }
    },
    "node_modules/rfdc": {
      "version": "1.4.1",
      "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
      "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
      "license": "MIT"
    },
    "node_modules/rollup": {
      "version": "4.57.1",
      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
      "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@types/estree": "1.0.8"
      },
      "bin": {
        "rollup": "dist/bin/rollup"
      },
      "engines": {
        "node": ">=18.0.0",
        "npm": ">=8.0.0"
      },
      "optionalDependencies": {
        "@rollup/rollup-android-arm-eabi": "4.57.1",
        "@rollup/rollup-android-arm64": "4.57.1",
        "@rollup/rollup-darwin-arm64": "4.57.1",
        "@rollup/rollup-darwin-x64": "4.57.1",
        "@rollup/rollup-freebsd-arm64": "4.57.1",
        "@rollup/rollup-freebsd-x64": "4.57.1",
        "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
        "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
        "@rollup/rollup-linux-arm64-gnu": "4.57.1",
        "@rollup/rollup-linux-arm64-musl": "4.57.1",
        "@rollup/rollup-linux-loong64-gnu": "4.57.1",
        "@rollup/rollup-linux-loong64-musl": "4.57.1",
        "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
        "@rollup/rollup-linux-ppc64-musl": "4.57.1",
        "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
        "@rollup/rollup-linux-riscv64-musl": "4.57.1",
        "@rollup/rollup-linux-s390x-gnu": "4.57.1",
        "@rollup/rollup-linux-x64-gnu": "4.57.1",
        "@rollup/rollup-linux-x64-musl": "4.57.1",
        "@rollup/rollup-openbsd-x64": "4.57.1",
        "@rollup/rollup-openharmony-arm64": "4.57.1",
        "@rollup/rollup-win32-arm64-msvc": "4.57.1",
        "@rollup/rollup-win32-ia32-msvc": "4.57.1",
        "@rollup/rollup-win32-x64-gnu": "4.57.1",
        "@rollup/rollup-win32-x64-msvc": "4.57.1",
        "fsevents": "~2.3.2"
      }
    },
    "node_modules/safe-buffer": {
      "version": "5.2.1",
      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/safe-regex2": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
      "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "ret": "~0.5.0"
      }
    },
    "node_modules/safe-stable-stringify": {
      "version": "2.5.0",
      "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
      "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
      "license": "MIT",
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/secure-json-parse": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
      "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/fastify"
        },
        {
          "type": "opencollective",
          "url": "https://opencollective.com/fastify"
        }
      ],
      "license": "BSD-3-Clause"
    },
    "node_modules/seedrandom": {
      "version": "3.0.5",
      "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
      "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
      "license": "MIT"
    },
    "node_modules/semver": {
      "version": "7.7.4",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/set-cookie-parser": {
      "version": "2.7.2",
      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
      "license": "MIT"
    },
    "node_modules/siginfo": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/sonic-boom": {
      "version": "4.2.1",
      "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
      "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
      "license": "MIT",
      "dependencies": {
        "atomic-sleep": "^1.0.0"
      }
    },
    "node_modules/source-map-js": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
      "dev": true,
      "license": "BSD-3-Clause",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/split2": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
      "license": "ISC",
      "engines": {
        "node": ">= 10.x"
      }
    },
    "node_modules/stackback": {
      "version": "0.0.2",
      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/std-env": {
      "version": "3.10.0",
      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
      "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/stream-shift": {
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
      "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
      "license": "MIT"
    },
    "node_modules/string_decoder": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
      "license": "MIT",
      "dependencies": {
        "safe-buffer": "~5.2.0"
      }
    },
    "node_modules/strip-json-comments": {
      "version": "5.0.3",
      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
      "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=14.16"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/strnum": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
      "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/NaturalIntelligence"
        }
      ],
      "license": "MIT"
    },
    "node_modules/supports-color": {
      "version": "7.2.0",
      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "has-flag": "^4.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/thread-stream": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
      "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
      "license": "MIT",
      "dependencies": {
        "real-require": "^0.2.0"
      },
      "engines": {
        "node": ">=20"
      }
    },
    "node_modules/tiny-emitter": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
      "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
      "license": "MIT"
    },
    "node_modules/tinybench": {
      "version": "2.9.0",
      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/tinyexec": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
      "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/tinyglobby": {
      "version": "0.2.15",
      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fdir": "^6.5.0",
        "picomatch": "^4.0.3"
      },
      "engines": {
        "node": ">=12.0.0"
      },
      "funding": {
        "url": "https://github.com/sponsors/SuperchupuDev"
      }
    },
    "node_modules/tinyrainbow": {
      "version": "3.0.3",
      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
      "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/toad-cache": {
      "version": "3.7.0",
      "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
      "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
      "license": "MIT",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/tr46": {
      "version": "0.0.3",
      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
      "license": "MIT"
    },
    "node_modules/tslib": {
      "version": "2.8.1",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
      "license": "0BSD"
    },
    "node_modules/tsx": {
      "version": "4.21.0",
      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
      "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "esbuild": "~0.27.0",
        "get-tsconfig": "^4.7.5"
      },
      "bin": {
        "tsx": "dist/cli.mjs"
      },
      "engines": {
        "node": ">=18.0.0"
      },
      "optionalDependencies": {
        "fsevents": "~2.3.3"
      }
    },
    "node_modules/typed-function": {
      "version": "4.2.2",
      "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz",
      "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==",
      "license": "MIT",
      "engines": {
        "node": ">= 18"
      }
    },
    "node_modules/typescript": {
      "version": "5.9.3",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "node_modules/undici-types": {
      "version": "6.21.0",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/util-deprecate": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
      "license": "MIT"
    },
    "node_modules/uuid": {
      "version": "9.0.1",
      "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
      "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
      "funding": [
        "https://github.com/sponsors/broofa",
        "https://github.com/sponsors/ctavan"
      ],
      "license": "MIT",
      "bin": {
        "uuid": "dist/bin/uuid"
      }
    },
    "node_modules/vite": {
      "version": "7.3.1",
      "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
      "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "esbuild": "^0.27.0",
        "fdir": "^6.5.0",
        "picomatch": "^4.0.3",
        "postcss": "^8.5.6",
        "rollup": "^4.43.0",
        "tinyglobby": "^0.2.15"
      },
      "bin": {
        "vite": "bin/vite.js"
      },
      "engines": {
        "node": "^20.19.0 || >=22.12.0"
      },
      "funding": {
        "url": "https://github.com/vitejs/vite?sponsor=1"
      },
      "optionalDependencies": {
        "fsevents": "~2.3.3"
      },
      "peerDependencies": {
        "@types/node": "^20.19.0 || >=22.12.0",
        "jiti": ">=1.21.0",
        "less": "^4.0.0",
        "lightningcss": "^1.21.0",
        "sass": "^1.70.0",
        "sass-embedded": "^1.70.0",
        "stylus": ">=0.54.8",
        "sugarss": "^5.0.0",
        "terser": "^5.16.0",
        "tsx": "^4.8.1",
        "yaml": "^2.4.2"
      },
      "peerDependenciesMeta": {
        "@types/node": {
          "optional": true
        },
        "jiti": {
          "optional": true
        },
        "less": {
          "optional": true
        },
        "lightningcss": {
          "optional": true
        },
        "sass": {
          "optional": true
        },
        "sass-embedded": {
          "optional": true
        },
        "stylus": {
          "optional": true
        },
        "sugarss": {
          "optional": true
        },
        "terser": {
          "optional": true
        },
        "tsx": {
          "optional": true
        },
        "yaml": {
          "optional": true
        }
      }
    },
    "node_modules/vitest": {
      "version": "4.0.18",
      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
      "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@vitest/expect": "4.0.18",
        "@vitest/mocker": "4.0.18",
        "@vitest/pretty-format": "4.0.18",
        "@vitest/runner": "4.0.18",
        "@vitest/snapshot": "4.0.18",
        "@vitest/spy": "4.0.18",
        "@vitest/utils": "4.0.18",
        "es-module-lexer": "^1.7.0",
        "expect-type": "^1.2.2",
        "magic-string": "^0.30.21",
        "obug": "^2.1.1",
        "pathe": "^2.0.3",
        "picomatch": "^4.0.3",
        "std-env": "^3.10.0",
        "tinybench": "^2.9.0",
        "tinyexec": "^1.0.2",
        "tinyglobby": "^0.2.15",
        "tinyrainbow": "^3.0.3",
        "vite": "^6.0.0 || ^7.0.0",
        "why-is-node-running": "^2.3.0"
      },
      "bin": {
        "vitest": "vitest.mjs"
      },
      "engines": {
        "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/vitest"
      },
      "peerDependencies": {
        "@edge-runtime/vm": "*",
        "@opentelemetry/api": "^1.9.0",
        "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
        "@vitest/browser-playwright": "4.0.18",
        "@vitest/browser-preview": "4.0.18",
        "@vitest/browser-webdriverio": "4.0.18",
        "@vitest/ui": "4.0.18",
        "happy-dom": "*",
        "jsdom": "*"
      },
      "peerDependenciesMeta": {
        "@edge-runtime/vm": {
          "optional": true
        },
        "@opentelemetry/api": {
          "optional": true
        },
        "@types/node": {
          "optional": true
        },
        "@vitest/browser-playwright": {
          "optional": true
        },
        "@vitest/browser-preview": {
          "optional": true
        },
        "@vitest/browser-webdriverio": {
          "optional": true
        },
        "@vitest/ui": {
          "optional": true
        },
        "happy-dom": {
          "optional": true
        },
        "jsdom": {
          "optional": true
        }
      }
    },
    "node_modules/webidl-conversions": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
      "license": "BSD-2-Clause"
    },
    "node_modules/whatwg-url": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
      "license": "MIT",
      "dependencies": {
        "tr46": "~0.0.3",
        "webidl-conversions": "^3.0.0"
      }
    },
    "node_modules/why-is-node-running": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "siginfo": "^2.0.0",
        "stackback": "0.0.2"
      },
      "bin": {
        "why-is-node-running": "cli.js"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/wrappy": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
      "license": "ISC"
    },
    "node_modules/ws": {
      "version": "8.19.0",
      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
      "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
      "license": "MIT",
      "engines": {
        "node": ">=10.0.0"
      },
      "peerDependencies": {
        "bufferutil": "^4.0.1",
        "utf-8-validate": ">=5.0.2"
      },
      "peerDependenciesMeta": {
        "bufferutil": {
          "optional": true
        },
        "utf-8-validate": {
          "optional": true
        }
      }
    },
    "node_modules/zod": {
      "version": "3.25.76",
      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/colinhacks"
      }
    }
  }
}

CLAUDE.md

Path: starbot_api/CLAUDE.md

Size: 7487 bytes

# CLAUDE.md — Starbot API

This is the backend for Starbot, an AI assistant system. It's a Fastify/TypeScript server that handles auth, chat persistence, memory injection, LLM streaming, and tool execution.

Live at `https://starbot.cloud/v1/` (proxied through nginx). Runs on port 3737.

---

## Commands

```bash
npm install              # Install deps
npm run dev              # Watch mode (tsx) — recommended for dev
npm run build            # Compile to dist/
npm start                # Run compiled dist/index.js
npm test                 # Vitest (single run)
npm run test:watch       # Vitest watch mode
npm run test:coverage    # Coverage report
npm run lint             # ESLint
npm run db:push          # Push schema changes to SQLite
npm run db:migrate       # Create + apply migration (interactive)
npm run db:studio        # Prisma Studio UI
```

## Deploy

```bash
./deploy.sh              # Build, push schema, install service, restart
```

The deploy script installs `deploy/starbot-api.service` to systemd and restarts. Logs: `sudo journalctl -u starbot-api -f`

---

## Environment

Copy `.env.example` to `.env`. Key vars:

- `DATABASE_URL=file:./dev.db` — SQLite, relative to `prisma/` dir (so the file is `prisma/dev.db`)
- `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_MODELS`, `AZURE_ALLOWED_DEPLOYMENTS` — Azure OpenAI
- `DEEPSEEK_API_KEY` — DeepSeek (R1 reasoning + V3 chat)
- `BRAVE_SEARCH_API_KEY` — web search tool
- `TOOLS_ENABLED=true` — enables tool execution (web search, calculator, code exec, file ops)

Legacy providers (Kimi, Vertex, Bedrock, Cloudflare) are in the codebase but disabled.

---

## Architecture

### Data Model

- **Project** — top-level container. Has PMEMORY.md (project-wide memory).
- **Workspace** — repo/folder/cloud resource within a project. Has MEMORY.md.
- **Chat** — conversation scoped to a project or workspace.
- **Message** — single message in a chat (`role`: "user" | "assistant").
- **MemoryDocument** — raw PMEMORY.md or MEMORY.md content.
- **MemoryChunk** — semantic chunks with embeddings for retrieval.

Schema is in `prisma/schema.prisma`. Database is SQLite at `prisma/dev.db`.

### Streaming Protocol

All generation goes through `POST /v1/chats/:chatId/run`. Response is SSE streamed via raw fetch (NOT EventSource):

- `message.start` / `message.delta` / `message.stop` — content
- `tool.call` / `tool.result` — tool execution
- `inference.complete` — done

### Memory Injection

1. Client calls `/v1/chats/:chatId/run`
2. API checks scope (workspace thread vs project thread)
3. `services/retrieval.ts` fetches relevant memory chunks via cosine similarity
4. Chunks injected into system prompt before LLM call
5. Workspace threads auto-inject workspace MEMORY.md; project threads auto-inject PMEMORY.md and can retrieve workspace memories via semantic search

### Tool Orchestration

When `TOOLS_ENABLED=true`, the orchestrator (`services/orchestrator/`) handles multi-turn tool calling:
- Parser extracts tool calls from model output
- Executor runs tools and returns results
- Orchestrator loops until the model stops calling tools

DeepSeek models need custom orchestration since they don't support native tool calling.

---

## API Routes

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/v1/projects` | List projects |
| POST | `/v1/projects` | Create project |
| GET/PUT | `/v1/projects/:id` | Get/update project |
| GET | `/v1/projects/:pid/chats` | List chats |
| POST | `/v1/projects/:pid/chats` | Create chat |
| GET/PUT | `/v1/chats/:id` | Get/update chat |
| GET | `/v1/chats/:id/messages` | List messages |
| POST | `/v1/chats/:id/messages` | Add message |
| POST | `/v1/chats/:id/run` | Stream generation (main endpoint) |
| GET/POST | `/v1/projects/:pid/workspaces` | List/create workspaces |
| GET/PUT | `/v1/projects/:pid/pmemory` | Project memory |
| GET/PUT | `/v1/projects/:pid/workspaces/:wid/memory` | Workspace memory |
| GET | `/v1/models` | List available models |
| GET | `/v1/health` | Health check |

Auth: `Authorization: Bearer <token>` on protected routes. Device auth flow in `routes/auth.ts`.

---

## Code Layout

```
src/
  index.ts                  — Server entry, route registration, CORS
  env.ts                    — Environment variable validation
  db.ts                     — Prisma client instance

  routes/
    generation.ts           — POST /v1/chats/:id/run (core streaming endpoint)
    projects.ts             — Project CRUD
    chats.ts                — Chat CRUD
    messages.ts             — Message CRUD
    workspaces.ts           — Workspace CRUD
    memory.ts               — Memory document GET/PUT
    models.ts               — GET /v1/models
    auth.ts                 — Auth flows
    inference.ts            — Legacy inference endpoints (WebGUI compat)
    folders.ts              — Folder operations
    tasks.ts                — Task management
    __tests__/              — Route tests (Vitest)

  providers/
    azure-openai.ts         — Azure OpenAI (primary)
    deepseek.ts             — DeepSeek R1/V3 (custom tool orchestration)
    types.ts                — Provider interface
    bedrock.ts, vertex.ts, cloudflare.ts, kimi.ts — Legacy (disabled)
    index.ts                — Provider registry

  services/
    retrieval.ts            — Semantic search, memory injection
    chunking.ts             — Split markdown into ~800-token chunks
    embeddings.ts           — OpenAI text-embedding-3-large
    model-catalog.ts        — Model definitions and capabilities
    web-search.ts           — Brave Search integration
    filesystem-router.ts    — Local file system navigation
    interpreter.ts          — Code execution
    message-preprocessor.ts — Pre-processing before LLM call
    codex-router.ts         — Codex header routing

    orchestrator/           — Tool calling loop
      orchestrator.ts       — Main loop
      parser.ts             — Extract tool calls from output
      executor.ts           — Run tools
      types.ts

    tools/                  — Tool implementations
      registry.ts           — Tool registry
      web-search-tool.ts
      calculator-tool.ts
      code-exec-tool.ts
      file-read-tool.ts
      shell-exec-tool.ts
      fs-glob-tool.ts, fs-grep-tool.ts, fs-edit-file-tool.ts, etc.
      __tests__/

    triage/                 — Request triage/routing
    task-manager/           — Task management service

  security/
    route-guards.ts         — Auth checks and request guards

prisma/
  schema.prisma             — Database schema
  dev.db                    — SQLite database (gitignored)

deploy/
  starbot-api.service       — Systemd unit file
```

---

## Common Tasks

**Add a new route:** Create handler in `routes/`, use Zod for validation, register in `index.ts`, add tests in `__tests__/`.

**Add a new provider:** Implement the interface in `providers/` (needs `streamChat()`), add env vars to `env.ts`, update `model-catalog.ts` and `/v1/models`.

**Change the schema:** Edit `prisma/schema.prisma`, then `npm run db:push` (dev) or `npm run db:migrate` (production).

**Debug streaming:** Check `npm run dev` console output. The generation route logs provider calls and errors.

---

## Sibling Repos

- **WebGUI** — `/var/www/sites/stella/starbot.cloud` (GitHub: `starbot-web`)
- **TUI** — Rust CLI client in the original monorepo at `/home/stella/projects/starbot/Starbot_TUI`
- **Monorepo** (reference) — `/home/stella/projects/starbot`

tsconfig.json

Path: starbot_api/tsconfig.json

Size: 520 bytes

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "lib": ["ES2022"],
    "moduleResolution": "bundler",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "allowSyntheticDefaultImports": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

.env.example

Path: starbot_api/.env.example

Size: 2137 bytes

# Starbot_API Environment Configuration
# Copy this file to .env and fill in your credentials

# ===== Server Configuration =====
PORT=3737
HOST=127.0.0.1
NODE_ENV=development
LOG_LEVEL=info
DATABASE_URL=file:./dev.db

# Optional API protection flags (off by default)
AUTH_ENFORCEMENT_ENABLED=false
RATE_LIMITING_ENABLED=false
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_RUN_PER_WINDOW=8
RATE_LIMIT_INFERENCE_PER_WINDOW=30

# ===== Kimi/Moonshot =====
# Sign up at: https://platform.moonshot.cn/
MOONSHOT_API_KEY=your_moonshot_api_key_here
MOONSHOT_BASE_URL=https://api.moonshot.cn

# ===== Google Vertex AI =====
# Setup: https://cloud.google.com/vertex-ai/docs/start/quickstarts
VERTEX_PROJECT_ID=your_gcp_project_id
VERTEX_LOCATION=us-central1
VERTEX_ALLOWED_MODELS=gemini-2.5-flash-lite,gemini-2.5-flash,gemini-2.5-pro,gemini-3-flash-preview
GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json

# ===== Azure AI Services (OpenAI) =====
# Setup: https://portal.azure.com/
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com
AZURE_OPENAI_API_KEY=your_azure_openai_key
# Comma-separated list of deployed model names
AZURE_OPENAI_MODELS=gpt-5.2,gpt-5.2-codex,gpt-5-mini
AZURE_ALLOWED_DEPLOYMENTS=gpt-5.2-chat,gpt-4.1

# ===== AWS Bedrock =====
# Setup: https://console.aws.amazon.com/bedrock/
AWS_ACCESS_KEY_ID=your_aws_access_key
AWS_SECRET_ACCESS_KEY=your_aws_secret_key
AWS_REGION=us-east-1
BEDROCK_REGION=us-east-1

# ===== Cloudflare Workers AI =====
# Setup: https://dash.cloudflare.com/
CLOUDFLARE_ACCOUNT_ID=your_cloudflare_account_id
CLOUDFLARE_API_TOKEN=your_cloudflare_api_token

# ===== Features =====
# Enable LLM-based triage (uses cheapest model to improve routing)
TRIAGE_MODEL_ENABLED=false

# Enable tool execution (file.read, file.dir, web.search, etc.)
TOOLS_ENABLED=true

# Enable web search (requires API key)
WEB_SEARCH_ENABLED=false
WEB_SEARCH_API_KEY=your_search_api_key
BRAVE_SEARCH_API_KEY=your_brave_search_api_key

# ===== Quick Start =====
# Minimum configuration to get started (Kimi only):
# 1. Copy this file to .env
# 2. Add your MOONSHOT_API_KEY
# 3. Run: pnpm dev
# 4. Test: ./test-api.sh

api-tui.ts

Path: starbot_api/scripts/api-tui.ts

Size: 4888 bytes

#!/usr/bin/env node
// Simple API TUI - Server management tool
// Shows API status and allows start/stop/restart

import { exec, spawn } from 'child_process';
import { promisify } from 'util';
import readline from 'readline';

const execAsync = promisify(exec);

const API_PORT = 3737;
const API_DIR = process.cwd();

let apiProcess: ReturnType<typeof spawn> | null = null;

async function checkApiRunning(): Promise<boolean> {
  try {
    const { stdout } = await execAsync(`lsof -i :${API_PORT} -sTCP:LISTEN -t`);
    return stdout.trim().length > 0;
  } catch {
    return false;
  }
}

async function getApiPid(): Promise<number | null> {
  try {
    const { stdout } = await execAsync(`lsof -i :${API_PORT} -sTCP:LISTEN -t`);
    const pid = parseInt(stdout.trim(), 10);
    return isNaN(pid) ? null : pid;
  } catch {
    return null;
  }
}

async function killPort(): Promise<void> {
  const pid = await getApiPid();
  if (pid) {
    console.log(`\nKilling process on port ${API_PORT} (PID: ${pid})...`);
    try {
      process.kill(pid, 'SIGKILL');
      await new Promise(r => setTimeout(r, 1000));
      console.log('Port freed.');
    } catch (e) {
      console.log('Failed to kill process:', e);
    }
  }
}

async function stopApi(): Promise<void> {
  const pid = await getApiPid();
  if (pid) {
    console.log(`\nStopping API (PID: ${pid})...`);
    try {
      process.kill(pid, 'SIGTERM');
      await new Promise(r => setTimeout(r, 2000));
      // Force kill if still running
      const stillRunning = await checkApiRunning();
      if (stillRunning) {
        process.kill(pid, 'SIGKILL');
      }
      console.log('API stopped.');
    } catch (e) {
      console.log('Failed to stop API:', e);
    }
  } else {
    console.log('\nAPI is not running.');
  }
}

async function startApi(): Promise<void> {
  const running = await checkApiRunning();
  if (running) {
    const pid = await getApiPid();
    console.log(`\nAPI is already running (PID: ${pid}).`);
    return;
  }

  console.log('\nStarting API...');
  apiProcess = spawn('npm', ['run', 'dev'], {
    cwd: API_DIR,
    stdio: 'inherit',
    shell: true,
  });

  // Wait for API to be ready
  await new Promise(r => setTimeout(r, 3000));
  console.log('API started.');
}

async function restartApi(): Promise<void> {
  console.log('\nRestarting API...');
  await killPort();
  await new Promise(r => setTimeout(r, 1000));
  await startApi();
}

function drawHeader() {
  console.clear();
  console.log('╔════════════════════════════════════════╗');
  console.log('║         Starbot API Manager            ║');
  console.log('╚════════════════════════════════════════╝');
  console.log('');
}

async function showStatus() {
  const running = await checkApiRunning();
  const pid = await getApiPid();

  console.log('┌────────────────────────────────────────┐');
  console.log('│  Status: ' + (running ? '🟢 Running  ' : '🔴 Stopped'));
  if (running && pid) {
    console.log('│  PID:    ' + pid.toString().padEnd(34));
  }
  console.log('│  Port:   ' + API_PORT.toString().padEnd(34));
  console.log('│  URL:    http://localhost:' + API_PORT + '/v1'.padEnd(26));
  console.log('└────────────────────────────────────────┘');
  console.log('');
}

function showMenu() {
  console.log('┌────────────────────────────────────────┐');
  console.log('│  [s] Start API                         │');
  console.log('│  [x] Stop API                          │');
  console.log('│  [k] Kill port (force stop)            │');
  console.log('│  [r] Restart API                       │');
  console.log('│  [t] Test API (curl health)            │');
  console.log('│  [q] Quit                              │');
  console.log('└────────────────────────────────────────┘');
  console.log('');
}

async function testApi(): Promise<void> {
  try {
    const { stdout } = await execAsync('curl -s http://localhost:3737/v1/health');
    console.log('\n✓ API Health Response:');
    console.log(stdout);
  } catch (e) {
    console.log('\n✗ API is not responding');
  }
}

async function main() {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });

  const question = (prompt: string): Promise<string> => {
    return new Promise(resolve => rl.question(prompt, resolve));
  };

  while (true) {
    drawHeader();
    await showStatus();
    showMenu();

    const choice = await question('Choose: ');

    switch (choice.toLowerCase()) {
      case 's':
        await startApi();
        break;
      case 'x':
        await stopApi();
        break;
      case 'k':
        await killPort();
        break;
      case 'r':
        await restartApi();
        break;
      case 't':
        await testApi();
        break;
      case 'q':
        rl.close();
        process.exit(0);
    }

    await question('\nPress Enter to continue...');
  }
}

main().catch(console.error);

db.ts

Path: starbot_api/src/db.ts

Size: 505 bytes

// Database client (Prisma + SQLite)
import { PrismaClient } from '@prisma/client';

if (!process.env.DATABASE_URL) {
  process.env.DATABASE_URL = 'file:../starbot.db';
}

export const prisma = new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});

// Graceful shutdown
process.on('SIGINT', async () => {
  await prisma.$disconnect();
  process.exit(0);
});

process.on('SIGTERM', async () => {
  await prisma.$disconnect();
  process.exit(0);
});

env.ts

Path: starbot_api/src/env.ts

Size: 5997 bytes

// Environment configuration for Starbot_API
// Load all provider credentials and settings from environment variables

const strEnv = (value: string | undefined, fallback = '') => (value ?? fallback).trim();

export const env = {
  // Server
  PORT: parseInt(process.env.PORT || '3737', 10),
  HOST: process.env.HOST || '127.0.0.1',
  NODE_ENV: process.env.NODE_ENV || 'development',

  // Kimi/Moonshot
  MOONSHOT_API_KEY: strEnv(process.env.MOONSHOT_API_KEY),
  MOONSHOT_BASE_URL: strEnv(process.env.MOONSHOT_BASE_URL, 'https://api.moonshot.cn'),

  // Google Vertex AI
  VERTEX_PROJECT_ID: strEnv(process.env.VERTEX_PROJECT_ID),
  VERTEX_LOCATION: strEnv(process.env.VERTEX_LOCATION, 'us-central1'),
  GOOGLE_APPLICATION_CREDENTIALS: strEnv(process.env.GOOGLE_APPLICATION_CREDENTIALS),
  VERTEX_ALLOWED_MODELS: (process.env.VERTEX_ALLOWED_MODELS || '')
    .split(',')
    .map(s => s.trim())
    .filter(Boolean),

  // Azure AI Services
  AZURE_OPENAI_ENDPOINT: strEnv(process.env.AZURE_OPENAI_ENDPOINT),
  AZURE_OPENAI_API_KEY: strEnv(process.env.AZURE_OPENAI_API_KEY),
  AZURE_OPENAI_MODELS: (process.env.AZURE_OPENAI_MODELS || '').split(',').filter(Boolean),
  AZURE_ALLOWED_DEPLOYMENTS: (process.env.AZURE_ALLOWED_DEPLOYMENTS || '')
    .split(',')
    .map(s => s.trim())
    .filter(Boolean),

  // Official DeepSeek API (for testing function calling)
  DEEPSEEK_API_KEY: strEnv(process.env.DEEPSEEK_API_KEY),

  // AWS Bedrock
  AWS_ACCESS_KEY_ID: strEnv(process.env.AWS_ACCESS_KEY_ID),
  AWS_SECRET_ACCESS_KEY: strEnv(process.env.AWS_SECRET_ACCESS_KEY),
  AWS_REGION: strEnv(process.env.AWS_REGION || process.env.BEDROCK_REGION, 'us-east-1'),
  BEDROCK_REGION: strEnv(process.env.BEDROCK_REGION, 'us-east-1'),

  // Cloudflare Workers AI
  CLOUDFLARE_ACCOUNT_ID: strEnv(process.env.CLOUDFLARE_ACCOUNT_ID),
  CLOUDFLARE_API_TOKEN: strEnv(process.env.CLOUDFLARE_API_TOKEN),
  INTERPRETER_ENABLED: process.env.INTERPRETER_ENABLED !== 'false',
  INTERPRETER_MODEL: strEnv(
    process.env.INTERPRETER_MODEL,
    '@cf/mistralai/mistral-small-3.1-24b-instruct',
  ),
  INTERPRETER_MAX_TOKENS: parseInt(process.env.INTERPRETER_MAX_TOKENS || '220', 10),

  // Codex Router (DISABLED - using DeepSeek directly)
  CODEX_ROUTER_ENABLED: false,
  CODEX_ROUTER_MODEL: strEnv(process.env.CODEX_ROUTER_MODEL, 'DeepSeek-R1'),
  CODEX_ROUTER_MAX_TOKENS: parseInt(process.env.CODEX_ROUTER_MAX_TOKENS || '300', 10),

  // Triage
  TRIAGE_MODEL_ENABLED: process.env.TRIAGE_MODEL_ENABLED === 'true',

  // Features
  TOOLS_ENABLED: process.env.TOOLS_ENABLED !== 'false', // Default true
  CODE_EXECUTION_ENABLED: process.env.CODE_EXECUTION_ENABLED === 'true', // Default false for security
  SHELL_EXEC_ENABLED: process.env.SHELL_EXEC_ENABLED === 'true', // Default false for security
  WEB_SEARCH_ENABLED: process.env.WEB_SEARCH_ENABLED === 'true',
  WEB_SEARCH_API_KEY: process.env.WEB_SEARCH_API_KEY || '',
  BRAVE_SEARCH_API_KEY: process.env.BRAVE_SEARCH_API_KEY || '',
  WORKSPACE_ROOT: strEnv(process.env.WORKSPACE_ROOT, process.cwd()),
  MEMORY_V2_ENABLED: process.env.MEMORY_V2_ENABLED === 'true',
  AUTH_ENFORCEMENT_ENABLED: process.env.AUTH_ENFORCEMENT_ENABLED === 'true',
  RATE_LIMITING_ENABLED: process.env.RATE_LIMITING_ENABLED === 'true',
  RATE_LIMIT_WINDOW_MS: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
  RATE_LIMIT_RUN_PER_WINDOW: parseInt(process.env.RATE_LIMIT_RUN_PER_WINDOW || '8', 10),
  RATE_LIMIT_MESSAGES_PER_WINDOW: parseInt(process.env.RATE_LIMIT_MESSAGES_PER_WINDOW || '100', 10),
  RATE_LIMIT_INFERENCE_PER_WINDOW: parseInt(process.env.RATE_LIMIT_INFERENCE_PER_WINDOW || '30', 10),
  RATE_LIMIT_COMPLETION_PER_WINDOW: parseInt(
    process.env.RATE_LIMIT_COMPLETION_PER_WINDOW || '60',
    10,
  ),

  // Logging
  LOG_LEVEL: process.env.LOG_LEVEL || 'info',
};

// Validation helpers
export function isProviderConfigured(provider: string): boolean {
  switch (provider) {
    case 'kimi':
      return !!env.MOONSHOT_API_KEY;
    case 'vertex':
      return !!env.VERTEX_PROJECT_ID;
    case 'azure':
      return !!env.AZURE_OPENAI_ENDPOINT && !!env.AZURE_OPENAI_API_KEY;
    case 'bedrock':
      return !!env.AWS_ACCESS_KEY_ID && !!env.AWS_SECRET_ACCESS_KEY;
    case 'cloudflare':
      return !!env.CLOUDFLARE_ACCOUNT_ID && !!env.CLOUDFLARE_API_TOKEN;
    case 'deepseek':
      return !!env.DEEPSEEK_API_KEY;
    default:
      return false;
  }
}

export function listConfiguredProviders(): string[] {
  const providers = ['kimi', 'vertex', 'azure', 'bedrock', 'cloudflare'];
  return providers.filter(isProviderConfigured);
}

// Log configuration on startup (redact secrets)
export function logConfiguration() {
  const configured = listConfiguredProviders();
  console.log('Starbot_API Configuration:');
  console.log(`  Environment: ${env.NODE_ENV}`);
  console.log(`  Server: ${env.HOST}:${env.PORT}`);
  console.log(`  Configured providers: ${configured.join(', ') || 'none'}`);
  console.log(`  Tools enabled: ${env.TOOLS_ENABLED}`);
  if (env.CODE_EXECUTION_ENABLED) {
    console.log(`  ⚠️  CODE EXECUTION ENABLED - Use with caution!`);
  }
  console.log(`  Web search enabled: ${env.WEB_SEARCH_ENABLED}`);
  console.log(`  Memory V2 enabled: ${env.MEMORY_V2_ENABLED}`);
  console.log(`  Codex router enabled: ${env.CODEX_ROUTER_ENABLED}`);
  console.log(`  Codex router model: ${env.CODEX_ROUTER_MODEL}`);
  console.log(`  Interpreter enabled: ${env.INTERPRETER_ENABLED}`);
  console.log(`  Interpreter model: ${env.INTERPRETER_MODEL}`);
  console.log(`  Triage model enabled: ${env.TRIAGE_MODEL_ENABLED}`);
  console.log(`  Auth enforcement enabled: ${env.AUTH_ENFORCEMENT_ENABLED}`);
  console.log(`  Rate limiting enabled: ${env.RATE_LIMITING_ENABLED}`);
  if (env.RATE_LIMITING_ENABLED) {
    console.log(`  Rate limit window ms: ${env.RATE_LIMIT_WINDOW_MS}`);
    console.log(`  /run max per window: ${env.RATE_LIMIT_RUN_PER_WINDOW}`);
    console.log(`  /inference/chat max per window: ${env.RATE_LIMIT_INFERENCE_PER_WINDOW}`);
  }
}

index.ts

Path: starbot_api/src/index.ts

Size: 3323 bytes

// Starbot API - The Brain
// Port: 3737 (localhost only)

// Load environment variables from .env file
import 'dotenv/config';

import Fastify from 'fastify';
import cors from '@fastify/cors';
import { env, logConfiguration } from './env.js';
import { projectRoutes } from './routes/projects.js';
import { chatRoutes } from './routes/chats.js';
import { messageRoutes } from './routes/messages.js';
import { generationRoutes } from './routes/generation.js';
import { modelRoutes } from './routes/models.js';
import { workspaceRoutes } from './routes/workspaces.js';
import { memoryRoutes } from './routes/memory.js';
import { authRoutes } from './routes/auth.js';
import { inferenceRoutes } from './routes/inference.js';
import { tasksRoutes } from './routes/tasks.js';
import { folderRoutes } from './routes/folders.js';
import { initializeTools } from './services/tools/index.js';

const PORT = env.PORT;
const HOST = env.HOST;

const server = Fastify({
  logger: {
    level: process.env.LOG_LEVEL || 'info',
    transport: {
      target: 'pino-pretty',
      options: {
        translateTime: 'HH:MM:ss Z',
        ignore: 'pid,hostname',
      },
    },
  },
});

// CORS for local development and production
await server.register(cors, {
  origin: [
    'http://localhost:8080',
    'http://127.0.0.1:8080',
    'http://localhost:3000',      // WebGUI (dev)
    'http://127.0.0.1:3000',
    'https://starbot.cloud',      // Production
    'https://www.starbot.cloud',  // Production (www)
    'https://sgauth0.com',
    'https://www.sgauth0.com',
    'https://console.sgauth0.com',
    'https://www.console.sgauth0.com',
    'https://console.starbot.cloud',
    'https://www.console.starbot.cloud',
    'http://starbot.cloud',       // Production (HTTP before SSL)
    'http://www.starbot.cloud',
    'http://sgauth0.com',
    'http://www.sgauth0.com',
    'http://console.sgauth0.com',
    'http://www.console.sgauth0.com',
    'http://console.starbot.cloud',
    'http://www.console.starbot.cloud',
  ],
  credentials: true,
});

// Main health endpoint with /v1 prefix
server.get('/v1/health', async () => {
  return {
    status: 'ok',
    timestamp: new Date().toISOString(),
    version: '1.0.0',
  };
});

// Legacy redirect
server.get('/health', async (request, reply) => {
  return reply.code(301).redirect('/v1/health');
});

// API routes
await server.register(projectRoutes, { prefix: '/v1' });
await server.register(chatRoutes, { prefix: '/v1' });
await server.register(messageRoutes, { prefix: '/v1' });
await server.register(generationRoutes, { prefix: '/v1' });
await server.register(modelRoutes, { prefix: '/v1' });
await server.register(workspaceRoutes, { prefix: '/v1' });
await server.register(memoryRoutes, { prefix: '/v1' });
await server.register(authRoutes, { prefix: '/v1' });
await server.register(inferenceRoutes, { prefix: '/v1' });
await server.register(tasksRoutes, { prefix: '/v1' });
await server.register(folderRoutes, { prefix: '/v1' });

// Initialize tools
initializeTools();

// Start server
try {
  await server.listen({ port: PORT, host: HOST });
  console.log(`🧠 Starbot API listening on http://${HOST}:${PORT}`);
  console.log(`📊 Health: http://${HOST}:${PORT}/health`);
  console.log('');
  logConfiguration();
} catch (err) {
  server.log.error(err);
  process.exit(1);
}

cloudflare.ts

Path: starbot_api/src/providers/cloudflare.ts

Size: 4464 bytes

// Cloudflare Workers AI Provider
// Simple REST API for Cloudflare's AI models

import type { Provider, ProviderMessage, ProviderOptions, ProviderResponse, StreamChunk } from './types.js';
import { env } from '../env.js';

export class CloudflareProvider implements Provider {
  name = 'cloudflare';

  async sendChat(messages: ProviderMessage[], options: ProviderOptions): Promise<ProviderResponse> {
    const accountId = env.CLOUDFLARE_ACCOUNT_ID;
    const apiToken = env.CLOUDFLARE_API_TOKEN;

    if (!accountId || !apiToken) {
      throw new Error('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are not configured');
    }

    const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${options.model}`;

    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        messages,
        max_tokens: options.maxTokens ?? 4096,
        temperature: options.temperature ?? 0.7,
        stream: false,
      }),
      signal: options.signal,
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Cloudflare API error (${response.status}): ${error}`);
    }

    const data = await response.json() as {
      success?: boolean;
      result?: {
        response?: string;
      };
      errors?: Array<{ message: string }>;
    };

    if (!data.success) {
      const errorMsg = data.errors?.map(e => e.message).join(', ') || 'Unknown error';
      throw new Error(`Cloudflare API error: ${errorMsg}`);
    }

    const content = data.result?.response || '';

    // Cloudflare doesn't return token counts, so we estimate
    const promptTokens = Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
    const completionTokens = Math.ceil(content.length / 4);

    return {
      content,
      usage: {
        promptTokens,
        completionTokens,
        totalTokens: promptTokens + completionTokens,
      },
    };
  }

  async *sendChatStream(messages: ProviderMessage[], options: ProviderOptions): AsyncIterable<StreamChunk> {
    const accountId = env.CLOUDFLARE_ACCOUNT_ID;
    const apiToken = env.CLOUDFLARE_API_TOKEN;

    if (!accountId || !apiToken) {
      throw new Error('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are not configured');
    }

    const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${options.model}`;

    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        messages,
        max_tokens: options.maxTokens ?? 4096,
        temperature: options.temperature ?? 0.7,
        stream: true,
      }),
      signal: options.signal,
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Cloudflare API error (${response.status}): ${error}`);
    }

    if (!response.body) {
      throw new Error('Response body is null');
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    let totalContent = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';

      for (const line of lines) {
        const trimmed = line.trim();
        if (!trimmed || !trimmed.startsWith('data: ')) continue;

        const data = trimmed.slice(6);
        if (data === '[DONE]') {
          // Send final usage estimate
          const promptTokens = Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
          const completionTokens = Math.ceil(totalContent.length / 4);

          yield {
            text: '',
            usage: {
              promptTokens,
              completionTokens,
              totalTokens: promptTokens + completionTokens,
            },
          };
          return;
        }

        try {
          const parsed = JSON.parse(data);
          const text = parsed.response || '';

          if (text) {
            totalContent += text;
            yield { text };
          }
        } catch (e) {
          // Skip malformed JSON
          continue;
        }
      }
    }
  }
}

vertex.ts

Path: starbot_api/src/providers/vertex.ts

Size: 5653 bytes

// Google Vertex AI Provider
// Uses @google-cloud/vertexai SDK for Gemini models

import { VertexAI } from '@google-cloud/vertexai';
import type { Provider, ProviderMessage, ProviderOptions, ProviderResponse, StreamChunk } from './types.js';
import { env } from '../env.js';

function isGemini3Model(model: string): boolean {
  return /^gemini-3(?:$|[.-])/.test(String(model || '').trim().toLowerCase());
}

function isModelAllowed(model: string): boolean {
  if (env.VERTEX_ALLOWED_MODELS.length === 0) return true;
  const allowed = new Set(env.VERTEX_ALLOWED_MODELS.map(s => s.toLowerCase()));
  return allowed.has(String(model || '').trim().toLowerCase());
}

export class VertexProvider implements Provider {
  name = 'vertex';
  private projectId: string;
  private location: string;
  private clients: Map<string, VertexAI>;

  constructor() {
    if (!env.VERTEX_PROJECT_ID) {
      throw new Error('VERTEX_PROJECT_ID is required');
    }
    this.projectId = env.VERTEX_PROJECT_ID;
    this.location = env.VERTEX_LOCATION || 'us-central1';
    this.clients = new Map();
  }

  private getClient(location: string): VertexAI {
    const loc = location.trim() || 'us-central1';
    const cached = this.clients.get(loc);
    if (cached) return cached;
    const client = new VertexAI({
      project: this.projectId,
      location: loc,
    });
    this.clients.set(loc, client);
    return client;
  }

  private preferredLocationForModel(model: string): string {
    // Gemini 3 preview is exposed under global in this project.
    return isGemini3Model(model) ? 'global' : this.location;
  }

  private formatMessages(messages: ProviderMessage[]): Array<{ role: string; parts: Array<{ text: string }> }> {
    // Vertex AI uses 'user' and 'model' roles (not 'assistant')
    return messages
      .filter(m => m.role !== 'system') // System messages handled separately
      .map(m => ({
        role: m.role === 'assistant' ? 'model' : m.role,
        parts: [{ text: m.content }],
      }));
  }

  private getSystemInstruction(messages: ProviderMessage[]): string | undefined {
    const systemMsg = messages.find(m => m.role === 'system');
    return systemMsg?.content;
  }

  async sendChat(messages: ProviderMessage[], options: ProviderOptions): Promise<ProviderResponse> {
    const modelName = options.model;
    if (!isModelAllowed(modelName)) {
      throw new Error(`Vertex model not allowed: ${modelName}`);
    }

    const callOnce = async (location: string) => {
      const model = this.getClient(location).getGenerativeModel({
        model: modelName,
        systemInstruction: this.getSystemInstruction(messages),
        generationConfig: {
          maxOutputTokens: options.maxTokens ?? 8192,
          temperature: options.temperature ?? 0.7,
        },
      });
      return model.generateContent({
        contents: this.formatMessages(messages),
      });
    };

    const preferredLocation = this.preferredLocationForModel(modelName);
    let result;
    try {
      result = await callOnce(preferredLocation);
    } catch (err: any) {
      const msg = String(err?.message || err);
      if (preferredLocation !== 'global' && msg.includes('404')) {
        result = await callOnce('global');
      } else {
        throw err;
      }
    }

    const response = result.response;
    const content = response.candidates?.[0]?.content?.parts?.[0]?.text || '';

    // Extract token usage if available
    const usageMetadata = response.usageMetadata;
    const promptTokens = usageMetadata?.promptTokenCount || 0;
    const completionTokens = usageMetadata?.candidatesTokenCount || 0;

    return {
      content,
      usage: {
        promptTokens,
        completionTokens,
        totalTokens: promptTokens + completionTokens,
      },
    };
  }

  async *sendChatStream(messages: ProviderMessage[], options: ProviderOptions): AsyncIterable<StreamChunk> {
    const modelName = options.model;
    if (!isModelAllowed(modelName)) {
      throw new Error(`Vertex model not allowed: ${modelName}`);
    }

    const callOnce = async (location: string) => {
      const model = this.getClient(location).getGenerativeModel({
        model: modelName,
        systemInstruction: this.getSystemInstruction(messages),
        generationConfig: {
          maxOutputTokens: options.maxTokens ?? 8192,
          temperature: options.temperature ?? 0.7,
        },
      });
      return model.generateContentStream({
        contents: this.formatMessages(messages),
      });
    };

    const preferredLocation = this.preferredLocationForModel(modelName);
    let result;
    try {
      result = await callOnce(preferredLocation);
    } catch (err: any) {
      const msg = String(err?.message || err);
      if (preferredLocation !== 'global' && msg.includes('404')) {
        result = await callOnce('global');
      } else {
        throw err;
      }
    }

    let promptTokens = 0;
    let completionTokens = 0;

    // Stream text chunks
    for await (const chunk of result.stream) {
      const text = chunk.candidates?.[0]?.content?.parts?.[0]?.text || '';

      if (text) {
        yield { text };
      }

      // Update token counts if available
      if (chunk.usageMetadata) {
        promptTokens = chunk.usageMetadata.promptTokenCount || 0;
        completionTokens = chunk.usageMetadata.candidatesTokenCount || 0;
      }
    }

    // Final usage report
    if (promptTokens > 0 || completionTokens > 0) {
      yield {
        text: '',
        usage: {
          promptTokens,
          completionTokens,
          totalTokens: promptTokens + completionTokens,
        },
      };
    }
  }
}

azure-openai.ts

Path: starbot_api/src/providers/azure-openai.ts

Size: 7677 bytes

// Azure AI Services Provider
// Uses direct REST API calls (Azure OpenAI uses standard OpenAI format)

import type { Provider, ProviderMessage, ProviderOptions, ProviderResponse, StreamChunk, ToolCall } from './types.js';
import { env } from '../env.js';

export class AzureProvider implements Provider {
  name = 'azure';

  private formatMessages(messages: ProviderMessage[]): Array<any> {
    return messages.map(m => {
      const formatted: any = {
        role: m.role,
        content: m.content,
      };

      // Add tool calls if present (for assistant messages)
      if (m.tool_calls) {
        formatted.tool_calls = m.tool_calls.map(tc => ({
          id: tc.id,
          type: 'function',
          function: {
            name: tc.name,
            arguments: tc.arguments,
          },
        }));
      }

      // Add tool call ID if present (for tool result messages)
      if (m.tool_call_id) {
        formatted.tool_call_id = m.tool_call_id;
      }

      // Add tool name if present (for tool result messages)
      if (m.name) {
        formatted.name = m.name;
      }

      return formatted;
    });
  }

  private getModelConfig(model: string, options: ProviderOptions) {
    // Handle model-specific quirks
    const isGPT5x = model.includes('gpt-5.');
    const isGPT41 = model.includes('gpt-4.1');

    // GPT-5.x and GPT-4.1 require max_completion_tokens instead of max_tokens
    const useCompletionTokens = isGPT5x || isGPT41;

    // GPT-5.x does NOT accept custom temperature (only default 1.0)
    const supportsTemperature = !isGPT5x;

    const config: any = {};

    if (useCompletionTokens) {
      config.max_completion_tokens = options.maxTokens ?? 4096;
    } else {
      config.max_tokens = options.maxTokens ?? 4096;
    }

    if (supportsTemperature) {
      config.temperature = options.temperature ?? 0.7;
    }

    return config;
  }

  async sendChat(messages: ProviderMessage[], options: ProviderOptions): Promise<ProviderResponse> {
    if (!env.AZURE_OPENAI_ENDPOINT || !env.AZURE_OPENAI_API_KEY) {
      throw new Error('AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY are required');
    }

    const config = this.getModelConfig(options.model, options);

    // Azure OpenAI URL format: {endpoint}/openai/deployments/{deployment-name}/chat/completions?api-version=...
    const url = `${env.AZURE_OPENAI_ENDPOINT}/openai/deployments/${options.model}/chat/completions?api-version=2024-12-01-preview`;

    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'api-key': env.AZURE_OPENAI_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        messages: this.formatMessages(messages),
        ...config,
      }),
      signal: options.signal,
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Azure OpenAI API error (${response.status}): ${error}`);
    }

    const data = await response.json() as any;
    const content = data.choices[0]?.message?.content || '';
    const usage = data.usage;

    return {
      content,
      usage: {
        promptTokens: usage?.prompt_tokens || 0,
        completionTokens: usage?.completion_tokens || 0,
        totalTokens: usage?.total_tokens || 0,
      },
    };
  }

  async *sendChatStream(messages: ProviderMessage[], options: ProviderOptions): AsyncIterable<StreamChunk> {
    if (!env.AZURE_OPENAI_ENDPOINT || !env.AZURE_OPENAI_API_KEY) {
      throw new Error('AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY are required');
    }

    const config = this.getModelConfig(options.model, options);
    const requestBody: any = {
      messages: this.formatMessages(messages),
      ...config,
      stream: true,
    };

    // Add tools to request if provided
    if (options.tools && options.tools.length > 0) {
      requestBody.tools = options.tools;
      requestBody.tool_choice = options.tool_choice || 'auto';
    }

    const url = `${env.AZURE_OPENAI_ENDPOINT}/openai/deployments/${options.model}/chat/completions?api-version=2024-12-01-preview`;

    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'api-key': env.AZURE_OPENAI_API_KEY,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(requestBody),
      signal: options.signal,
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Azure OpenAI API error (${response.status}): ${error}`);
    }

    if (!response.body) {
      throw new Error('Response body is null');
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    let promptTokens = 0;
    let completionTokens = 0;
    let accumulatedToolCalls: Map<number, ToolCall> = new Map();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';

      for (const line of lines) {
        const trimmed = line.trim();
        if (!trimmed || !trimmed.startsWith('data: ')) continue;

        const data = trimmed.slice(6);
        if (data === '[DONE]') {
          // Send final usage and tool calls if available
          const toolCalls = Array.from(accumulatedToolCalls.values());
          if (promptTokens > 0 || completionTokens > 0 || toolCalls.length > 0) {
            yield {
              text: '',
              tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
              usage: {
                promptTokens,
                completionTokens,
                totalTokens: promptTokens + completionTokens,
              },
            };
          }
          return;
        }

        try {
          const parsed = JSON.parse(data);
          const delta = parsed.choices?.[0]?.delta;

          // Handle reasoning content (DeepSeek R1 thinking)
          if (delta?.reasoning_content) {
            yield { text: delta.reasoning_content, reasoning: true };
          }

          // Handle text content
          if (delta?.content) {
            yield { text: delta.content };
          }

          // Handle tool calls in streaming
          if (delta?.tool_calls) {
            for (const toolCallDelta of delta.tool_calls) {
              const index = toolCallDelta.index;
              const existing = accumulatedToolCalls.get(index) || {
                id: '',
                name: '',
                arguments: '',
              };

              if (toolCallDelta.id) existing.id = toolCallDelta.id;
              if (toolCallDelta.function?.name) existing.name = toolCallDelta.function.name;
              if (toolCallDelta.function?.arguments) {
                existing.arguments += toolCallDelta.function.arguments;
              }

              accumulatedToolCalls.set(index, existing);
            }
          }

          // Handle finish reason
          const finishReason = parsed.choices?.[0]?.finish_reason;
          if (finishReason) {
            const toolCalls = Array.from(accumulatedToolCalls.values());
            yield {
              text: '',
              tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
              finish_reason: finishReason,
            };
          }

          // Azure sometimes includes usage in stream
          if (parsed.usage) {
            promptTokens = parsed.usage.prompt_tokens || 0;
            completionTokens = parsed.usage.completion_tokens || 0;
          }
        } catch (e) {
          // Skip malformed JSON
          continue;
        }
      }
    }
  }
}

bedrock.ts

Path: starbot_api/src/providers/bedrock.ts

Size: 7979 bytes

// AWS Bedrock Provider
// Uses @aws-sdk/client-bedrock-runtime for Claude, Mistral, DeepSeek

import {
  BedrockRuntimeClient,
  InvokeModelCommand,
  InvokeModelWithResponseStreamCommand,
} from '@aws-sdk/client-bedrock-runtime';
import type { Provider, ProviderMessage, ProviderOptions, ProviderResponse, StreamChunk } from './types.js';
import { env } from '../env.js';

export class BedrockProvider implements Provider {
  name = 'bedrock';
  private client: BedrockRuntimeClient;

  constructor() {
    if (!env.AWS_ACCESS_KEY_ID || !env.AWS_SECRET_ACCESS_KEY || !env.BEDROCK_REGION) {
      throw new Error('AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and BEDROCK_REGION are required');
    }

    this.client = new BedrockRuntimeClient({
      region: env.BEDROCK_REGION,
      credentials: {
        accessKeyId: env.AWS_ACCESS_KEY_ID,
        secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
      },
    });
  }

  private getModelFamily(modelId: string): 'claude' | 'mistral' | 'deepseek' {
    if (modelId.includes('anthropic.claude')) return 'claude';
    if (modelId.includes('mistral')) return 'mistral';
    if (modelId.includes('deepseek')) return 'deepseek';
    throw new Error(`Unknown Bedrock model family: ${modelId}`);
  }

  private formatRequestClaude(messages: ProviderMessage[], options: ProviderOptions) {
    const systemMsg = messages.find(m => m.role === 'system');
    const conversationMsgs = messages
      .filter(m => m.role !== 'system')
      .map(m => ({
        role: m.role,
        content: m.content,
      }));

    return {
      anthropic_version: 'bedrock-2023-05-31',
      max_tokens: options.maxTokens ?? 4096,
      temperature: options.temperature ?? 0.7,
      messages: conversationMsgs,
      ...(systemMsg ? { system: systemMsg.content } : {}),
    };
  }

  private formatRequestMistral(messages: ProviderMessage[], options: ProviderOptions) {
    return {
      messages: messages.map(m => ({
        role: m.role,
        content: m.content,
      })),
      max_tokens: options.maxTokens ?? 4096,
      temperature: options.temperature ?? 0.7,
    };
  }

  private formatRequestDeepSeek(messages: ProviderMessage[], options: ProviderOptions) {
    // DeepSeek uses prompt-based format with special delimiters
    let prompt = '';
    for (const msg of messages) {
      if (msg.role === 'system') {
        prompt += msg.content + '\n\n';
      } else if (msg.role === 'user') {
        prompt += `<｜User｜>${msg.content}<｜Assistant｜>`;
      } else if (msg.role === 'assistant') {
        prompt += msg.content + '\n\n';
      }
    }

    return {
      prompt,
      max_tokens: options.maxTokens ?? 4096,
      temperature: options.temperature ?? 0.7,
    };
  }

  private stripThinkTags(text: string): string {
    // Remove DeepSeek R1 thinking tags
    return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
  }

  async sendChat(messages: ProviderMessage[], options: ProviderOptions): Promise<ProviderResponse> {
    const family = this.getModelFamily(options.model);

    let requestBody: any;
    if (family === 'claude') {
      requestBody = this.formatRequestClaude(messages, options);
    } else if (family === 'mistral') {
      requestBody = this.formatRequestMistral(messages, options);
    } else {
      requestBody = this.formatRequestDeepSeek(messages, options);
    }

    const command = new InvokeModelCommand({
      modelId: options.model,
      contentType: 'application/json',
      accept: 'application/json',
      body: JSON.stringify(requestBody),
    });

    const response = await this.client.send(command);
    const responseBody = JSON.parse(new TextDecoder().decode(response.body));

    let content = '';
    let promptTokens = 0;
    let completionTokens = 0;

    if (family === 'claude') {
      // Claude: { content: [{ text: "..." }], usage: { input_tokens, output_tokens } }
      content = responseBody.content?.[0]?.text || '';
      promptTokens = responseBody.usage?.input_tokens || 0;
      completionTokens = responseBody.usage?.output_tokens || 0;
    } else if (family === 'mistral') {
      // Mistral: { choices: [{ message: { content: "..." } }] }
      content = responseBody.choices?.[0]?.message?.content || '';
      // Mistral doesn't return token counts in response, estimate
      promptTokens = Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
      completionTokens = Math.ceil(content.length / 4);
    } else {
      // DeepSeek: { choices: [{ text: "..." }] }
      content = responseBody.choices?.[0]?.text || '';
      content = this.stripThinkTags(content);
      // DeepSeek doesn't return token counts, estimate
      promptTokens = Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
      completionTokens = Math.ceil(content.length / 4);
    }

    return {
      content,
      usage: {
        promptTokens,
        completionTokens,
        totalTokens: promptTokens + completionTokens,
      },
    };
  }

  async *sendChatStream(messages: ProviderMessage[], options: ProviderOptions): AsyncIterable<StreamChunk> {
    const family = this.getModelFamily(options.model);

    let requestBody: any;
    if (family === 'claude') {
      requestBody = this.formatRequestClaude(messages, options);
    } else if (family === 'mistral') {
      requestBody = this.formatRequestMistral(messages, options);
    } else {
      requestBody = this.formatRequestDeepSeek(messages, options);
    }

    const command = new InvokeModelWithResponseStreamCommand({
      modelId: options.model,
      contentType: 'application/json',
      accept: 'application/json',
      body: JSON.stringify(requestBody),
    });

    const response = await this.client.send(command);

    let fullContent = '';
    let promptTokens = 0;
    let completionTokens = 0;

    if (response.body) {
      for await (const event of response.body) {
        if (event.chunk) {
          const chunk = JSON.parse(new TextDecoder().decode(event.chunk.bytes));

          let text = '';

          if (family === 'claude') {
            // Claude streaming: { type: "content_block_delta", delta: { text: "..." } }
            if (chunk.type === 'content_block_delta') {
              text = chunk.delta?.text || '';
            } else if (chunk.type === 'message_delta') {
              promptTokens = chunk.usage?.input_tokens || promptTokens;
              completionTokens = chunk.usage?.output_tokens || completionTokens;
            }
          } else if (family === 'mistral') {
            // Mistral streaming: { choices: [{ delta: { content: "..." } }] }
            text = chunk.choices?.[0]?.delta?.content || '';
          } else {
            // DeepSeek streaming: { choices: [{ text: "..." }] }
            text = chunk.choices?.[0]?.text || '';
          }

          if (text) {
            // Strip think tags for DeepSeek in real-time
            if (family === 'deepseek') {
              fullContent += text;
              // Only yield if we're not inside a think tag
              if (!fullContent.includes('<think>') || fullContent.includes('</think>')) {
                const cleaned = this.stripThinkTags(fullContent);
                const newText = cleaned.substring(fullContent.length - text.length);
                if (newText) {
                  yield { text: newText };
                }
              }
            } else {
              fullContent += text;
              yield { text };
            }
          }
        }
      }
    }

    // Estimate tokens if not provided
    if (promptTokens === 0) {
      promptTokens = Math.ceil(messages.reduce((sum, m) => sum + m.content.length, 0) / 4);
    }
    if (completionTokens === 0) {
      completionTokens = Math.ceil(fullContent.length / 4);
    }

    // Final usage report
    yield {
      text: '',
      usage: {
        promptTokens,
        completionTokens,
        totalTokens: promptTokens + completionTokens,
      },
    };
  }
}

types.ts

Path: starbot_api/src/providers/types.ts

Size: 1729 bytes

// Provider Interface for Starbot_API
// Common interface that all providers must implement

export interface ToolCall {
  id: string;
  name: string;
  arguments: string; // JSON string
}

export interface ProviderTool {
  type: 'function';
  function: {
    name: string;
    description: string;
    parameters: {
      type: 'object';
      properties: Record<string, any>;
      required: string[];
    };
  };
}

export interface ProviderMessage {
  type?: 'message'; // Required by DeepSeek API
  role: 'user' | 'assistant' | 'system' | 'tool';
  content: string;
  tool_calls?: ToolCall[]; // For assistant messages with tool calls
  tool_call_id?: string; // For tool result messages
  name?: string; // Tool name for tool messages
}

export interface ProviderOptions {
  model: string;
  maxTokens?: number;
  temperature?: number;
  signal?: AbortSignal;
  tools?: ProviderTool[]; // NEW: For function calling
  tool_choice?: 'auto' | 'none'; // NEW: Tool choice strategy
}

export interface ProviderUsage {
  promptTokens: number;
  completionTokens: number;
  totalTokens: number;
}

export interface ProviderResponse {
  content: string;
  usage: ProviderUsage;
}

export interface StreamChunk {
  text: string;
  reasoning?: boolean; // true if this is reasoning/thinking content (DeepSeek R1)
  usage?: ProviderUsage;
  tool_calls?: ToolCall[]; // NEW: Tool calls in streaming response
  finish_reason?: 'stop' | 'tool_calls' | 'length'; // NEW: Why generation stopped
}

export interface Provider {
  name: string;
  sendChat(messages: ProviderMessage[], options: ProviderOptions): Promise<ProviderResponse>;
  sendChatStream(messages: ProviderMessage[], options: ProviderOptions): AsyncIterable<StreamChunk>;
}

deepseek.ts

Path: starbot_api/src/providers/deepseek.ts

Size: 6251 bytes

// DeepSeek Provider
// Uses the official DeepSeek API which supports function calling

import { env } from '../env.js';
import type { Provider, ProviderMessage, ProviderOptions, ProviderResponse, StreamChunk, ToolCall } from './types.js';

export class DeepSeekProvider implements Provider {
  name = 'deepseek';
  private apiKey: string;
  private baseUrl = 'https://api.deepseek.com';

  constructor() {
    this.apiKey = env.DEEPSEEK_API_KEY || '';
    if (!this.apiKey) {
      throw new Error('DEEPSEEK_API_KEY not configured');
    }
  }

  async sendChat(messages: ProviderMessage[], options: ProviderOptions): Promise<ProviderResponse> {
    const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.apiKey}`,
      },
      body: JSON.stringify({
        model: options.model || 'deepseek-chat',
        messages: messages.map(m => {
          // Ensure content is always a string
          const content = typeof m.content === 'string' ? m.content : String(m.content || '');
          const base: any = { type: 'message', role: m.role, content };
          // Include tool_call_id and name for tool messages (required by DeepSeek)
          if (m.role === 'tool') {
            base.tool_call_id = m.tool_call_id;
            if (m.name) base.name = m.name;
          }
          // Include tool_calls for assistant messages with tools
          if (m.role === 'assistant' && m.tool_calls) {
            base.tool_calls = m.tool_calls.map(tc => ({
              id: tc.id,
              type: 'function',
              function: {
                name: tc.name,
                arguments: tc.arguments,
              },
            }));
          }
          return base;
        }),
        max_tokens: options.maxTokens,
        temperature: options.temperature,
        stream: false,
        tools: options.tools,
        tool_choice: options.tool_choice,
      }),
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`DeepSeek API error: ${response.status} - ${error}`);
    }

    const data = await response.json() as any;
    const message = data.choices[0]?.message;

    return {
      content: message.content || '',
      usage: {
        promptTokens: data.usage?.prompt_tokens || 0,
        completionTokens: data.usage?.completion_tokens || 0,
        totalTokens: data.usage?.total_tokens || 0,
      },
    };
  }

  async *sendChatStream(messages: ProviderMessage[], options: ProviderOptions): AsyncIterable<StreamChunk> {
    const response = await fetch(`${this.baseUrl}/v1/chat/completions`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.apiKey}`,
      },
      body: JSON.stringify({
        model: options.model || 'deepseek-chat',
        messages: messages.map(m => {
          // Ensure content is always a string
          const content = typeof m.content === 'string' ? m.content : String(m.content || '');
          const base: any = { type: 'message', role: m.role, content };
          // Include tool_call_id and name for tool messages (required by DeepSeek)
          if (m.role === 'tool') {
            base.tool_call_id = m.tool_call_id;
            if (m.name) base.name = m.name;
          }
          // Include tool_calls for assistant messages with tools
          if (m.role === 'assistant' && m.tool_calls) {
            base.tool_calls = m.tool_calls.map(tc => ({
              id: tc.id,
              type: 'function',
              function: {
                name: tc.name,
                arguments: tc.arguments,
              },
            }));
          }
          return base;
        }),
        max_tokens: options.maxTokens || 4096,
        temperature: options.temperature || 0.7,
        stream: true,
        tools: options.tools,
        tool_choice: options.tool_choice,
      }),
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`DeepSeek API error: ${response.status} - ${error}`);
    }

    if (!response.body) {
      throw new Error('No response body');
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    // Handle reasoning content for deepseek-reasoner
    let inReasoning = false;
    let reasoningContent = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';

      for (const line of lines) {
        const trimmed = line.trim();
        if (!trimmed || !trimmed.startsWith('data: ')) continue;

        const data = trimmed.slice(6);
        if (data === '[DONE]') return;

        try {
          const parsed = JSON.parse(data) as any;
          const delta = parsed.choices?.[0]?.delta;

          // Handle reasoning content
          if (delta?.reasoning_content) {
            yield { text: delta.reasoning_content, reasoning: true };
          }

          // Handle regular content
          if (delta?.content) {
            yield { text: delta.content };
          }

          // Handle tool calls
          if (delta?.tool_calls) {
            for (const tc of delta.tool_calls) {
              yield {
                text: '',
                tool_calls: [{
                  id: tc.id || '',
                  // @ts-ignore - type might not exist in all versions
                  type: 'function',
                  function: {
                    name: tc.function?.name || '',
                    arguments: tc.function?.arguments || '',
                  },
                } as any],
              };
            }
          }

          // Handle finish reason
          const finishReason = parsed.choices?.[0]?.finish_reason;
          if (finishReason) {
            yield {
              text: '',
              finish_reason: finishReason as 'stop' | 'tool_calls' | 'length',
            };
          }
        } catch {
          // Skip malformed JSON
        }
      }
    }
  }
}

kimi.ts

Path: starbot_api/src/providers/kimi.ts

Size: 3925 bytes

// Kimi Provider (Moonshot API)
// Simple REST API with OpenAI-compatible format

import type { Provider, ProviderMessage, ProviderOptions, ProviderResponse, StreamChunk } from './types.js';
import { env } from '../env.js';

export class KimiProvider implements Provider {
  name = 'kimi';

  async sendChat(messages: ProviderMessage[], options: ProviderOptions): Promise<ProviderResponse> {
    const apiKey = env.MOONSHOT_API_KEY;
    if (!apiKey) {
      throw new Error('MOONSHOT_API_KEY is not configured');
    }

    const baseUrl = env.MOONSHOT_BASE_URL.replace(/\/+$/, '');
    const response = await fetch(`${baseUrl}/v1/chat/completions`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${apiKey}`,
      },
      body: JSON.stringify({
        model: options.model,
        messages,
        max_tokens: options.maxTokens ?? 4096,
        temperature: options.temperature ?? 0.7,
      }),
      signal: options.signal,
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Kimi API error (${response.status}): ${error}`);
    }

    const data = await response.json() as {
      choices?: Array<{ message?: { content?: string } }>;
      usage?: {
        prompt_tokens?: number;
        completion_tokens?: number;
        total_tokens?: number;
      };
    };

    const content = data.choices?.[0]?.message?.content || '';

    return {
      content,
      usage: {
        promptTokens: data.usage?.prompt_tokens || 0,
        completionTokens: data.usage?.completion_tokens || 0,
        totalTokens: data.usage?.total_tokens || 0,
      },
    };
  }

  async *sendChatStream(messages: ProviderMessage[], options: ProviderOptions): AsyncIterable<StreamChunk> {
    const apiKey = env.MOONSHOT_API_KEY;
    if (!apiKey) {
      throw new Error('MOONSHOT_API_KEY is not configured');
    }

    const baseUrl = env.MOONSHOT_BASE_URL.replace(/\/+$/, '');
    const response = await fetch(`${baseUrl}/v1/chat/completions`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${apiKey}`,
      },
      body: JSON.stringify({
        model: options.model,
        messages,
        max_tokens: options.maxTokens ?? 4096,
        temperature: options.temperature ?? 0.7,
        stream: true,
      }),
      signal: options.signal,
    });

    if (!response.ok) {
      const error = await response.text();
      throw new Error(`Kimi API error (${response.status}): ${error}`);
    }

    if (!response.body) {
      throw new Error('Response body is null');
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';

      for (const line of lines) {
        const trimmed = line.trim();
        if (!trimmed || !trimmed.startsWith('data: ')) continue;

        const data = trimmed.slice(6);
        if (data === '[DONE]') return;

        try {
          const parsed = JSON.parse(data);
          const delta = parsed.choices?.[0]?.delta?.content;

          if (typeof delta === 'string' && delta) {
            yield { text: delta };
          }

          // Check for usage in final chunk
          if (parsed.usage) {
            yield {
              text: '',
              usage: {
                promptTokens: parsed.usage.prompt_tokens || 0,
                completionTokens: parsed.usage.completion_tokens || 0,
                totalTokens: parsed.usage.total_tokens || 0,
              },
            };
          }
        } catch (e) {
          // Skip malformed JSON chunks
          continue;
        }
      }
    }
  }
}

index.ts

Path: starbot_api/src/providers/index.ts

Size: 2009 bytes

// Provider Registry
// Central registry for all LLM providers

import type { Provider } from './types.js';
import { KimiProvider } from './kimi.js';
import { CloudflareProvider } from './cloudflare.js';
import { VertexProvider } from './vertex.js';
import { AzureProvider } from './azure-openai.js';
import { BedrockProvider } from './bedrock.js';
import { DeepSeekProvider } from './deepseek.js';
import { isProviderConfigured } from '../env.js';

// Provider instances (lazy initialization)
const providers: Map<string, Provider> = new Map();

function getOrCreateProvider(name: string): Provider | null {
  // Return cached instance if exists
  if (providers.has(name)) {
    return providers.get(name)!;
  }

  // Check if provider is configured
  if (!isProviderConfigured(name)) {
    return null;
  }

  // Create new instance based on name
  let provider: Provider | null = null;

  switch (name) {
    case 'kimi':
      provider = new KimiProvider();
      break;
    case 'cloudflare':
      provider = new CloudflareProvider();
      break;
    case 'vertex':
      provider = new VertexProvider();
      break;
    case 'azure':
      provider = new AzureProvider();
      break;
    case 'bedrock':
      provider = new BedrockProvider();
      break;
    case 'deepseek':
      provider = new DeepSeekProvider();
      break;
    default:
      return null;
  }

  if (provider) {
    providers.set(name, provider);
  }

  return provider;
}

export function getProvider(name: string): Provider {
  const provider = getOrCreateProvider(name);

  if (!provider) {
    throw new Error(`Provider "${name}" is not available or not configured`);
  }

  return provider;
}

export function listAvailableProviders(): string[] {
  const allProviders = ['kimi', 'vertex', 'azure', 'bedrock', 'cloudflare', 'deepseek'];
  return allProviders.filter(isProviderConfigured);
}

// Re-export types
export type { Provider, ProviderMessage, ProviderOptions, ProviderResponse, StreamChunk } from './types.js';

workspaces.ts

Path: starbot_api/src/routes/workspaces.ts

Size: 2075 bytes

import { FastifyPluginAsync } from 'fastify';
import { prisma } from '../db.js';

export const workspaceRoutes: FastifyPluginAsync = async (server) => {
  // List workspaces for a project
  server.get('/projects/:projectId/workspaces', async (request, reply) => {
    const { projectId } = request.params as { projectId: string };

    const workspaces = await prisma.workspace.findMany({
      where: { projectId },
      orderBy: { createdAt: 'desc' },
    });

    return { workspaces };
  });

  // Create workspace
  server.post('/projects/:projectId/workspaces', async (request, reply) => {
    const { projectId } = request.params as { projectId: string };
    const { type, identifier } = request.body as {
      type: 'repo' | 'folder' | 'cloud';
      identifier: string;
    };

    // Verify project exists
    const project = await prisma.project.findUnique({
      where: { id: projectId },
    });

    if (!project) {
      return reply.status(404).send({ error: 'Project not found' });
    }

    const workspace = await prisma.workspace.create({
      data: {
        projectId,
        type,
        identifier,
      },
    });

    return { workspace };
  });

  // Get workspace with chats
  server.get('/workspaces/:id', async (request, reply) => {
    const { id } = request.params as { id: string };

    const workspace = await prisma.workspace.findUnique({
      where: { id },
      include: {
        chats: {
          orderBy: { updatedAt: 'desc' },
        },
      },
    });

    if (!workspace) {
      return reply.status(404).send({ error: 'Workspace not found' });
    }

    return { workspace };
  });

  // Delete workspace
  server.delete('/workspaces/:id', async (request, reply) => {
    const { id } = request.params as { id: string };

    const workspace = await prisma.workspace.findUnique({
      where: { id },
    });

    if (!workspace) {
      return reply.status(404).send({ error: 'Workspace not found' });
    }

    await prisma.workspace.delete({
      where: { id },
    });

    return { success: true };
  });
};

chats.ts

Path: starbot_api/src/routes/chats.ts

Size: 3513 bytes

// Chat routes
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';

const CreateChatSchema = z.object({
  title: z.string().min(1).max(255).optional(),
  folderId: z.string().uuid().optional(),
  workspaceId: z.string().uuid().optional(),
  clientSource: z.enum(["webgui", "cli"]).optional(),
});

const UpdateChatSchema = z.object({
  title: z.string().min(1).max(255).optional(),
  folderId: z.string().uuid().optional().nullable(),
});

export async function chatRoutes(server: FastifyInstance) {
  // GET /v1/projects/:projectId/chats - List chats in a project
  server.get<{ Params: { projectId: string }; Querystring: { clientSource?: string } }>(
    '/projects/:projectId/chats',
    async (request, reply) => {
      const { projectId } = request.params;
      const { clientSource } = request.query;

      const where: { projectId: string; clientSource?: string } = { projectId };
      if (clientSource) {
        where.clientSource = clientSource;
      }

      const chats = await prisma.chat.findMany({
        where,
        orderBy: { updatedAt: 'desc' },
        include: {
          _count: {
            select: { messages: true },
          },
          folder: {
            select: { id: true, name: true },
          },
        },
      });

      return { chats };
    }
  );

  // POST /v1/projects/:projectId/chats - Create a new chat
  server.post<{ Params: { projectId: string } }>(
    '/projects/:projectId/chats',
    async (request, reply) => {
      const { projectId } = request.params;
      const body = CreateChatSchema.parse(request.body);

      // Verify project exists
      const project = await prisma.project.findUnique({
        where: { id: projectId },
      });

      if (!project) {
        return reply.code(404).send({ error: 'Project not found' });
      }

      const chat = await prisma.chat.create({
        data: {
          projectId,
          title: body.title || 'New Chat',
          folderId: body.folderId,
          workspaceId: body.workspaceId,
          clientSource: body.clientSource || 'webgui',
        },
      });

      return reply.code(201).send({ chat });
    }
  );

  // GET /v1/chats/:id - Get a specific chat
  server.get<{ Params: { id: string } }>('/chats/:id', async (request, reply) => {
    const { id } = request.params;

    const chat = await prisma.chat.findUnique({
      where: { id },
      include: {
        project: true,
        folder: true,
        messages: {
          orderBy: { createdAt: 'asc' },
        },
      },
    });

    if (!chat) {
      return reply.code(404).send({ error: 'Chat not found' });
    }

    return { chat };
  });

  // PUT /v1/chats/:id - Update a chat
  server.put<{ Params: { id: string } }>('/chats/:id', async (request, reply) => {
    const { id } = request.params;
    const body = UpdateChatSchema.parse(request.body);

    try {
      const chat = await prisma.chat.update({
        where: { id },
        data: body,
      });
      return { chat };
    } catch (err) {
      return reply.code(404).send({ error: 'Chat not found' });
    }
  });

  // DELETE /v1/chats/:id - Delete a chat
  server.delete<{ Params: { id: string } }>('/chats/:id', async (request, reply) => {
    const { id } = request.params;

    try {
      await prisma.chat.delete({
        where: { id },
      });
      return { ok: true };
    } catch (err) {
      return reply.code(404).send({ error: 'Chat not found' });
    }
  });
}

memory.ts

Path: starbot_api/src/routes/memory.ts

Size: 11517 bytes

import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { processContent } from '../services/chunking.js';
import { generateEmbeddingsBatch, areEmbeddingsAvailable } from '../services/embeddings.js';
import { searchChatMemory, searchMemory } from '../services/retrieval.js';

const DEFAULT_IDENTITY = `# IDENTITY

You are Starbot's default assistant identity.

## Core behavior
- Be accurate and explicit
- Prefer practical solutions
- Explain tradeoffs briefly
`;

const DEFAULT_PMEMORY = `# Project Memory

This document stores project-level context and conventions.

## Purpose
- Document architectural decisions
- Store coding conventions
- Track important project-wide patterns

## Guidelines
- Keep content organized with markdown headings
- Update this document as the project evolves
- Use this to guide AI assistants about project preferences
`;

const DEFAULT_WORKSPACE_MEMORY = `# Workspace Memory

This document stores workspace-specific context.

## Purpose
- Document workspace structure
- Store frequently used commands
- Track workspace-specific patterns

## Guidelines
- Keep content focused on this workspace
- Update as you discover patterns
- Use this to guide context-aware operations
`;

const DEFAULT_CHAT_MEMORY = `# Chat Memory

This document stores chat-specific context, decisions, and preferences.

## Purpose
- Keep durable thread context
- Track decisions made in this chat
- Preserve key user preferences for this thread

## Guidelines
- Keep entries concise and factual
- Remove stale or incorrect entries
- Prefer short bullet lists
`;

const MemoryContentSchema = z.object({
  content: z.string(),
});

const ProjectMemorySearchSchema = z.object({
  query: z.string().min(1),
  workspaceId: z.string().optional(),
  topK: z.number().int().min(1).max(20).optional().default(5),
});

const ChatMemorySearchSchema = z.object({
  query: z.string().min(1),
  topK: z.number().int().min(1).max(20).optional().default(5),
});

async function processMemory(memoryId: string, content: string) {
  await prisma.memoryChunk.deleteMany({
    where: { memoryId },
  });

  const chunks = processContent(content);
  if (chunks.length === 0) {
    return { status: 'success', chunks: 0, embeddings: 0 };
  }

  let embeddingsGenerated = 0;
  const embeddings = areEmbeddingsAvailable()
    ? await generateEmbeddingsBatch(chunks.map((c) => c.text))
    : chunks.map(() => null);

  const chunkData = chunks.map((chunk, index) => {
    const embedding = embeddings[index];
    if (embedding) embeddingsGenerated++;
    return {
      memoryId,
      text: chunk.text,
      embeddingVector: embedding ? JSON.stringify(embedding) : null,
    };
  });

  await prisma.memoryChunk.createMany({
    data: chunkData,
  });

  return {
    status: 'success',
    chunks: chunks.length,
    embeddings: embeddingsGenerated,
  };
}

function toMemoryEnvelope(memory: { id: string; content: string; updatedAt: Date }) {
  return {
    memory: {
      id: memory.id,
      content: memory.content,
      updatedAt: memory.updatedAt,
    },
  };
}

async function ensureIdentityMemory() {
  let memory = await prisma.memoryDocument.findFirst({
    where: {
      scope: 'identity',
      projectId: null,
      workspaceId: null,
      chatId: null,
    },
  });

  if (!memory) {
    memory = await prisma.memoryDocument.create({
      data: {
        scope: 'identity',
        projectId: null,
        workspaceId: null,
        chatId: null,
        content: DEFAULT_IDENTITY,
      },
    });
  }

  return memory;
}

async function ensureProjectMemory(projectId: string) {
  let memory = await prisma.memoryDocument.findFirst({
    where: {
      scope: 'project',
      projectId,
      workspaceId: null,
      chatId: null,
    },
  });

  if (!memory) {
    memory = await prisma.memoryDocument.create({
      data: {
        scope: 'project',
        projectId,
        workspaceId: null,
        chatId: null,
        content: DEFAULT_PMEMORY,
      },
    });
  }

  return memory;
}

async function ensureWorkspaceMemory(workspaceId: string) {
  let memory = await prisma.memoryDocument.findFirst({
    where: {
      scope: 'workspace',
      projectId: null,
      workspaceId,
      chatId: null,
    },
  });

  if (!memory) {
    memory = await prisma.memoryDocument.create({
      data: {
        scope: 'workspace',
        projectId: null,
        workspaceId,
        chatId: null,
        content: DEFAULT_WORKSPACE_MEMORY,
      },
    });
  }

  return memory;
}

async function ensureChatMemory(chatId: string) {
  let memory = await prisma.memoryDocument.findFirst({
    where: {
      scope: 'chat',
      chatId,
      projectId: null,
      workspaceId: null,
    },
  });

  if (!memory) {
    memory = await prisma.memoryDocument.create({
      data: {
        scope: 'chat',
        chatId,
        projectId: null,
        workspaceId: null,
        content: DEFAULT_CHAT_MEMORY,
      },
    });
  }

  return memory;
}

export const memoryRoutes: FastifyPluginAsync = async (server) => {
  // Global identity memory (IDENTITY.md)
  server.get('/identity', async () => {
    const memory = await ensureIdentityMemory();
    return toMemoryEnvelope(memory);
  });

  server.put('/identity', async (request) => {
    const { content } = MemoryContentSchema.parse(request.body);
    const memory = await ensureIdentityMemory();
    const updated = await prisma.memoryDocument.update({
      where: { id: memory.id },
      data: { content },
    });

    return toMemoryEnvelope(updated);
  });

  server.post('/identity/process', async () => {
    const memory = await ensureIdentityMemory();
    return processMemory(memory.id, memory.content);
  });

  // Get project memory (PMEMORY.md)
  server.get('/projects/:projectId/memory', async (request, reply) => {
    const { projectId } = request.params as { projectId: string };

    const project = await prisma.project.findUnique({
      where: { id: projectId },
    });

    if (!project) {
      return reply.status(404).send({ error: 'Project not found' });
    }

    const memory = await ensureProjectMemory(projectId);
    return toMemoryEnvelope(memory);
  });

  // Update project memory
  server.put('/projects/:projectId/memory', async (request, reply) => {
    const { projectId } = request.params as { projectId: string };
    const { content } = MemoryContentSchema.parse(request.body);

    const project = await prisma.project.findUnique({
      where: { id: projectId },
    });

    if (!project) {
      return reply.status(404).send({ error: 'Project not found' });
    }

    const memory = await ensureProjectMemory(projectId);
    const updated = await prisma.memoryDocument.update({
      where: { id: memory.id },
      data: { content },
    });

    return toMemoryEnvelope(updated);
  });

  // Get workspace memory (MEMORY.md)
  server.get('/workspaces/:workspaceId/memory', async (request, reply) => {
    const { workspaceId } = request.params as { workspaceId: string };

    const workspace = await prisma.workspace.findUnique({
      where: { id: workspaceId },
    });

    if (!workspace) {
      return reply.status(404).send({ error: 'Workspace not found' });
    }

    const memory = await ensureWorkspaceMemory(workspaceId);
    return toMemoryEnvelope(memory);
  });

  // Update workspace memory
  server.put('/workspaces/:workspaceId/memory', async (request, reply) => {
    const { workspaceId } = request.params as { workspaceId: string };
    const { content } = MemoryContentSchema.parse(request.body);

    const workspace = await prisma.workspace.findUnique({
      where: { id: workspaceId },
    });

    if (!workspace) {
      return reply.status(404).send({ error: 'Workspace not found' });
    }

    const memory = await ensureWorkspaceMemory(workspaceId);
    const updated = await prisma.memoryDocument.update({
      where: { id: memory.id },
      data: { content },
    });

    return toMemoryEnvelope(updated);
  });

  // Get chat-specific memory (MEMORY.md)
  server.get('/chats/:chatId/memory', async (request, reply) => {
    const { chatId } = request.params as { chatId: string };

    const chat = await prisma.chat.findUnique({
      where: { id: chatId },
    });

    if (!chat) {
      return reply.status(404).send({ error: 'Chat not found' });
    }

    const memory = await ensureChatMemory(chatId);
    return toMemoryEnvelope(memory);
  });

  // Update chat-specific memory
  server.put('/chats/:chatId/memory', async (request, reply) => {
    const { chatId } = request.params as { chatId: string };
    const { content } = MemoryContentSchema.parse(request.body);

    const chat = await prisma.chat.findUnique({
      where: { id: chatId },
    });

    if (!chat) {
      return reply.status(404).send({ error: 'Chat not found' });
    }

    const memory = await ensureChatMemory(chatId);
    const updated = await prisma.memoryDocument.update({
      where: { id: memory.id },
      data: { content },
    });

    return toMemoryEnvelope(updated);
  });

  // Process project memory (generate chunks and embeddings)
  server.post('/projects/:projectId/memory/process', async (request, reply) => {
    const { projectId } = request.params as { projectId: string };

    const memory = await prisma.memoryDocument.findFirst({
      where: {
        scope: 'project',
        projectId,
        workspaceId: null,
        chatId: null,
      },
    });

    if (!memory) {
      return reply.status(404).send({ error: 'Memory document not found' });
    }

    return processMemory(memory.id, memory.content);
  });

  // Process workspace memory
  server.post('/workspaces/:workspaceId/memory/process', async (request, reply) => {
    const { workspaceId } = request.params as { workspaceId: string };

    const memory = await prisma.memoryDocument.findFirst({
      where: {
        scope: 'workspace',
        projectId: null,
        workspaceId,
        chatId: null,
      },
    });

    if (!memory) {
      return reply.status(404).send({ error: 'Memory document not found' });
    }

    return processMemory(memory.id, memory.content);
  });

  // Process chat memory
  server.post('/chats/:chatId/memory/process', async (request, reply) => {
    const { chatId } = request.params as { chatId: string };

    const chat = await prisma.chat.findUnique({
      where: { id: chatId },
    });
    if (!chat) {
      return reply.status(404).send({ error: 'Chat not found' });
    }

    const memory = await ensureChatMemory(chatId);
    return processMemory(memory.id, memory.content);
  });

  // Search project/workspace memory
  server.post('/projects/:projectId/memory/search', async (request) => {
    const { projectId } = request.params as { projectId: string };
    const { query, workspaceId, topK } = ProjectMemorySearchSchema.parse(request.body);
    const results = await searchMemory(query, projectId, workspaceId, topK);
    return { results };
  });

  // Search chat memory
  server.post('/chats/:chatId/memory/search', async (request, reply) => {
    const { chatId } = request.params as { chatId: string };
    const { query, topK } = ChatMemorySearchSchema.parse(request.body);

    const chat = await prisma.chat.findUnique({
      where: { id: chatId },
    });
    if (!chat) {
      return reply.status(404).send({ error: 'Chat not found' });
    }

    const results = await searchChatMemory(query, chatId, topK);
    return { results };
  });
};

inference.ts

Path: starbot_api/src/routes/inference.ts

Size: 12127 bytes

/**
 * Inference Route - Legacy compatibility for TUI
 * Wraps the chat-based API in a simpler request/response format
 */

import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { runTriage } from '../services/triage/index.js';
import {
  getBestModelForTier,
  getModelById,
  getModelByProviderAndName,
  listModels,
  type ModelDefinition,
} from '../services/model-catalog.js';
import { interpretUserMessage } from '../services/interpreter.js';
import { getProvider } from '../providers/index.js';
import { getChatMemoryContext, getIdentityContext, getRelevantContext } from '../services/retrieval.js';
import { formatWebSearchContext, searchWeb } from '../services/web-search.js';
import { executeFilesystemRouterPrompt } from '../services/filesystem-router.js';
import { env } from '../env.js';
import { enforceRateLimitIfEnabled, requireAuthIfEnabled } from '../security/route-guards.js';

const InferenceRequestSchema = z.object({
  messages: z.array(z.object({
    role: z.enum(['user', 'assistant', 'system']),
    content: z.string(),
  })),
  client: z.string().optional(),
  provider: z.string().optional(),
  model: z.string().optional(),
  max_tokens: z.number().optional(),
  conversationId: z.string().optional(),
  client_context: z
    .object({
      working_dir: z.string().optional(),
    })
    .optional(),
});

const KNOWN_PROVIDERS = new Set(['kimi', 'vertex', 'azure', 'bedrock', 'cloudflare']);

function parseModelPrefs(raw?: string): { provider?: string; model?: string } {
  const trimmed = String(raw || '').trim();
  if (!trimmed) return {};
  if (trimmed.includes(':')) {
    const [providerRaw, modelRaw] = trimmed.split(':', 2);
    const provider = providerRaw.trim().toLowerCase();
    const model = modelRaw.trim();
    return {
      provider: provider || undefined,
      model: model || undefined,
    };
  }
  const lower = trimmed.toLowerCase();
  if (KNOWN_PROVIDERS.has(lower)) {
    return { provider: lower };
  }
  return { model: trimmed };
}

function sortByCost(models: ModelDefinition[]): ModelDefinition[] {
  return [...models].sort((a, b) => {
    const aCost = a.costPer1kInput || Number.POSITIVE_INFINITY;
    const bCost = b.costPer1kInput || Number.POSITIVE_INFINITY;
    return aCost - bCost;
  });
}

async function resolveRequestedModel(
  tier: number,
  capability: string,
  modelPrefs?: string,
): Promise<ModelDefinition | null> {
  const prefs = parseModelPrefs(modelPrefs);

  if (prefs.model) {
    if (prefs.provider) {
      const exact = await getModelByProviderAndName(prefs.provider, prefs.model);
      if (exact && exact.status === 'enabled') return exact;
    }

    const byId = await getModelById(prefs.model);
    if (byId && byId.status === 'enabled' && (!prefs.provider || byId.provider === prefs.provider)) {
      return byId;
    }

    const all = await listModels({
      status: 'enabled',
      capability,
      configuredOnly: true,
      ...(prefs.provider ? { provider: prefs.provider } : {}),
    });
    const byDeployment = all.find(m => m.deploymentName === prefs.model);
    if (byDeployment) return byDeployment;
  }

  if (prefs.provider) {
    const atTier = await listModels({
      status: 'enabled',
      tier,
      capability,
      configuredOnly: true,
      provider: prefs.provider,
    });
    if (atTier.length > 0) return sortByCost(atTier)[0];

    const anyTier = await listModels({
      status: 'enabled',
      capability,
      configuredOnly: true,
      provider: prefs.provider,
    });
    if (anyTier.length > 0) return sortByCost(anyTier)[0];
  }

  return getBestModelForTier(tier, capability, true);
}

function buildModelPrefs(provider?: string, model?: string): string | undefined {
  const providerTrimmed = String(provider || '').trim().toLowerCase();
  const modelTrimmed = String(model || '').trim();

  if (providerTrimmed && providerTrimmed !== 'auto' && modelTrimmed) {
    return `${providerTrimmed}:${modelTrimmed}`;
  }

  if (providerTrimmed && providerTrimmed !== 'auto') {
    return providerTrimmed;
  }

  if (modelTrimmed) {
    return modelTrimmed;
  }

  return undefined;
}

export const inferenceRoutes: FastifyPluginAsync = async (server) => {
  // POST /v1/inference/chat - Legacy endpoint for TUI
  server.post('/inference/chat', async (request, reply) => {
    if (!requireAuthIfEnabled(request, reply)) {
      return;
    }

    if (!enforceRateLimitIfEnabled(request, reply, {
      routeKey: 'inference_chat',
      maxRequests: env.RATE_LIMIT_INFERENCE_PER_WINDOW,
    })) {
      return;
    }

    const body = InferenceRequestSchema.parse(request.body);

    // Find or create a default project for CLI usage
    let project = await prisma.project.findFirst({
      where: { name: 'CLI Default' },
    });

    if (!project) {
      project = await prisma.project.create({
        data: { name: 'CLI Default' },
      });
    }

    // Find or create chat
    let chat;
    if (body.conversationId) {
      chat = await prisma.chat.findUnique({
        where: { id: body.conversationId },
        include: { messages: { orderBy: { createdAt: 'asc' } } },
      });
    }

    if (!chat) {
      chat = await prisma.chat.create({
        data: {
          projectId: project.id,
          title: 'CLI Chat',
          clientSource: 'cli',
        },
        include: { messages: true },
      });
    }

    // Add user messages from request
    for (const msg of body.messages) {
      await prisma.message.create({
        data: {
          chatId: chat.id,
          role: msg.role,
          content: msg.content,
        },
      });
    }

    // Get latest messages for context
    const messages = await prisma.message.findMany({
      where: { chatId: chat.id },
      orderBy: { createdAt: 'asc' },
    });

    const lastUserMsg = messages
      .filter((m: { role: string }) => m.role === 'user')
      .pop();
    if (!lastUserMsg) {
      return reply.code(400).send({ error: 'No user message found' });
    }

    // Interpreter pass (Cloudflare) before triage/model routing.
    const interpretation = await interpretUserMessage(lastUserMsg.content);
    const interpretedUserMessage = interpretation.normalizedUserMessage || lastUserMsg.content;
    const clarification = interpretation.clarificationQuestion?.trim();

    if (interpretation.shouldClarify && clarification) {
      await prisma.message.create({
        data: {
          chatId: chat.id,
          role: 'assistant',
          content: clarification,
        },
      });

      return {
        reply: clarification,
        conversation_id: chat.id,
        provider: 'cloudflare',
        model: env.INTERPRETER_MODEL,
        usage: {
          prompt_tokens: 0,
          completion_tokens: 0,
          total_tokens: 0,
        },
        interpreter: {
          action: 'clarify',
          primary_intent: interpretation.primaryIntent,
          intents: interpretation.intents,
          confidence: interpretation.confidence,
          reason: interpretation.reason || null,
        },
      };
    }

    if (interpretation.primaryIntent === 'filesystem') {
      const fsResponse = await executeFilesystemRouterPrompt(
        interpretedUserMessage,
        body.client_context?.working_dir,
      );

      await prisma.message.create({
        data: {
          chatId: chat.id,
          role: 'assistant',
          content: fsResponse,
        },
      });

      return {
        reply: fsResponse,
        conversation_id: chat.id,
        provider: 'cloudflare',
        model: env.INTERPRETER_MODEL,
        usage: {
          prompt_tokens: 0,
          completion_tokens: 0,
          total_tokens: 0,
        },
        interpreter: {
          action: 'execute',
          primary_intent: interpretation.primaryIntent,
          intents: interpretation.intents,
          confidence: interpretation.confidence,
          reason: interpretation.reason || null,
        },
      };
    }

    // Run triage
    const triageResult = runTriage({
      user_message: interpretedUserMessage,
      mode: 'standard',
    });

    const tierMap = { quick: 1, standard: 2, deep: 3 };
    const tier = tierMap[triageResult.decision.lane];

    const modelPrefs = buildModelPrefs(body.provider, body.model);
    const requestedExplicitly = !!modelPrefs;

    // Select model (honor provider/model preferences when supplied)
    const selectedModel = await resolveRequestedModel(tier, 'text', modelPrefs);
    if (!selectedModel) {
      if (requestedExplicitly) {
        return reply.code(400).send({
          error: 'Requested provider/model is not available',
        });
      }
      return reply.code(500).send({ error: 'No models available' });
    }

    // Get provider
    const provider = getProvider(selectedModel.provider);

    // Get memory context
    let memoryContext = '';
    let identityContext = '';
    let chatMemoryContext = '';
    let webSearchContext = '';
    if (interpretation.primaryIntent === 'browse') {
      try {
        const result = await searchWeb(interpretedUserMessage, 5);
        if (result && result.hits.length > 0) {
          webSearchContext = formatWebSearchContext(result);
        }
      } catch (err) {
        // Continue without web search
      }
    }
    try {
      if (env.MEMORY_V2_ENABLED) {
        [identityContext, chatMemoryContext] = await Promise.all([
          getIdentityContext(interpretedUserMessage, 3),
          getChatMemoryContext(interpretedUserMessage, chat.id, 5),
        ]);
      } else {
        memoryContext = await getRelevantContext(
          interpretedUserMessage,
          project.id,
          undefined,
          5
        );
      }
    } catch (err) {
      // Continue without memory
    }

    // Build provider messages
    const providerMessages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> = [];

    if (identityContext) {
      providerMessages.push({ role: 'system', content: identityContext });
    }

    if (chatMemoryContext) {
      providerMessages.push({ role: 'system', content: chatMemoryContext });
    }

    if (webSearchContext) {
      providerMessages.push({ role: 'system', content: webSearchContext });
    }

    if (memoryContext) {
      providerMessages.push({ role: 'system', content: memoryContext });
    }

    let replaced = false;
    providerMessages.push(
      ...messages.map((m: { role: string; content: string }) => {
        let content = m.content;
        if (!replaced && m.role === 'user' && content === lastUserMsg.content) {
          content = interpretedUserMessage;
          replaced = true;
        }
        return {
          role: m.role as 'user' | 'assistant' | 'system',
          content,
        };
      }),
    );

    // Generate response (non-streaming for CLI)
    let fullResponse = '';
    let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };

    for await (const chunk of provider.sendChatStream(providerMessages, {
      model: selectedModel.deploymentName,
      maxTokens: body.max_tokens || selectedModel.maxOutputTokens,
      temperature: 0.7,
    })) {
      if (chunk.text) {
        fullResponse += chunk.text;
      }
      if (chunk.usage) {
        usage = chunk.usage;
      }
    }

    // Save assistant message
    await prisma.message.create({
      data: {
        chatId: chat.id,
        role: 'assistant',
        content: fullResponse,
      },
    });

    // Return response in expected format
    return {
      reply: fullResponse,
      conversation_id: chat.id,
      provider: selectedModel.provider,
      model: selectedModel.deploymentName,
      usage: {
        prompt_tokens: usage.promptTokens,
        completion_tokens: usage.completionTokens,
        total_tokens: usage.totalTokens,
      },
      interpreter: {
        action: 'execute',
        primary_intent: interpretation.primaryIntent,
        intents: interpretation.intents,
        confidence: interpretation.confidence,
        reason: interpretation.reason || null,
      },
    };
  });
};

models.ts

Path: starbot_api/src/routes/models.ts

Size: 902 bytes

import type { FastifyInstance } from 'fastify';
import { listModels } from '../services/model-catalog.js';

export async function modelRoutes(server: FastifyInstance) {
  // Public: list configured models for pickers/clients.
  server.get('/models', async () => {
    const models = await listModels({ status: 'enabled', configuredOnly: true });
    const sorted = [...models].sort((a, b) => {
      if (a.tier !== b.tier) return a.tier - b.tier;
      return a.displayName.localeCompare(b.displayName);
    });

    const providers = [
      { id: 'auto', label: 'Auto' },
      ...sorted.map((m) => ({
        id: `${m.provider}:${m.deploymentName}`,
        provider: m.provider,
        model: m.deploymentName,
        label: m.displayName,
        tier: m.tier,
        capabilities: m.capabilities,
      })),
    ];

    return {
      defaultProvider: 'auto',
      providers,
    };
  });
}

generation.ts

Path: starbot_api/src/routes/generation.ts

Size: 44381 bytes

// Generation route (streaming with real model routing)
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import {
  getBestModelForTier,
  getModelById,
  getModelByProviderAndName,
  listModels,
  type ModelDefinition,
} from '../services/model-catalog.js';
import { interpretUserMessage } from '../services/interpreter.js';
import { classifyWithCodex, serializeHeader, stripHeader, type CodexHeader } from '../services/codex-router.js';
import { getProvider } from '../providers/index.js';
import type { ProviderMessage, ToolCall } from '../providers/types.js';
import { getChatMemoryContext, getIdentityContext, getRelevantContext } from '../services/retrieval.js';
import { formatWebSearchContext, searchWeb } from '../services/web-search.js';
import { toolRegistry, getToolsByNames } from '../services/tools/index.js';
import { DeepSeekOrchestrator } from '../services/orchestrator/index.js';
import { env } from '../env.js';
import { runTriage } from '../services/triage/index.js';
import { enforceRateLimitIfEnabled, requireAuthIfEnabled } from '../security/route-guards.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const RunChatSchema = z.object({
  mode: z.enum(['quick', 'standard', 'deep']).optional().default('standard'),
  model_prefs: z.string().optional(),
  speed: z.boolean().optional().default(false),
  auto: z.boolean().optional().default(true),
  client_context: z
    .object({
      working_dir: z.string().optional(),
    })
    .optional(),
});

const CompletionSchema = z.object({
  file_path: z.string(),
  content: z.string(),
  cursor_position: z.object({
    line: z.number().min(0),
    column: z.number().min(0),
  }),
  surrounding_lines: z.object({
    before: z.array(z.string()).default([]),
    after: z.array(z.string()).default([]),
  }).default({ before: [], after: [] }),
  max_suggestions: z.number().min(1).max(10).default(3),
  language: z.string().optional(),
});

const FileListSchema = z.object({
  workspace_id: z.string(),
  path: z.string().default('.'),
});

const FileReadSchema = z.object({
  workspace_id: z.string(),
  path: z.string(),
});

const FileWriteSchema = z.object({
  workspace_id: z.string(),
  file_path: z.string(),
  content: z.string(),
  create_backup: z.boolean().default(false),
});

interface RunParams {
  Params: {
    chatId: string;
  };
}

const KNOWN_PROVIDERS = new Set(['kimi', 'vertex', 'azure', 'bedrock', 'cloudflare']);

function parseModelPrefs(raw?: string): { provider?: string; model?: string } {
  const trimmed = String(raw || '').trim();
  if (!trimmed) return {};
  if (trimmed.includes(':')) {
    const [providerRaw, modelRaw] = trimmed.split(':', 2);
    const provider = providerRaw.trim().toLowerCase();
    const model = modelRaw.trim();
    return {
      provider: provider || undefined,
      model: model || undefined,
    };
  }
  const lower = trimmed.toLowerCase();
  if (KNOWN_PROVIDERS.has(lower)) {
    return { provider: lower };
  }
  return { model: trimmed };
}

function sortByCost(models: ModelDefinition[]): ModelDefinition[] {
  return [...models].sort((a, b) => {
    const aCost = a.costPer1kInput || Number.POSITIVE_INFINITY;
    const bCost = b.costPer1kInput || Number.POSITIVE_INFINITY;
    return aCost - bCost;
  });
}

function sortFallbackCandidates(models: ModelDefinition[], targetTier: number): ModelDefinition[] {
  return [...models].sort((a, b) => {
    const aTierDistance = Math.abs(a.tier - targetTier);
    const bTierDistance = Math.abs(b.tier - targetTier);
    if (aTierDistance !== bTierDistance) return aTierDistance - bTierDistance;

    const aCost = a.costPer1kInput || Number.POSITIVE_INFINITY;
    const bCost = b.costPer1kInput || Number.POSITIVE_INFINITY;
    return aCost - bCost;
  });
}

async function resolveRequestedModel(
  tier: number,
  capability: string,
  modelPrefs?: string,
): Promise<ModelDefinition | null> {
  const prefs = parseModelPrefs(modelPrefs);

  if (prefs.model) {
    if (prefs.provider) {
      const exact = await getModelByProviderAndName(prefs.provider, prefs.model);
      if (exact && exact.status === 'enabled') return exact;
    }

    const byId = await getModelById(prefs.model);
    if (byId && byId.status === 'enabled' && (!prefs.provider || byId.provider === prefs.provider)) {
      return byId;
    }

    const all = await listModels({
      status: 'enabled',
      capability,
      configuredOnly: true,
      ...(prefs.provider ? { provider: prefs.provider } : {}),
    });
    const byDeployment = all.find(m => m.deploymentName === prefs.model);
    if (byDeployment) return byDeployment;
  }

  if (prefs.provider) {
    const atTier = await listModels({
      status: 'enabled',
      tier,
      capability,
      configuredOnly: true,
      provider: prefs.provider,
    });
    if (atTier.length > 0) return sortByCost(atTier)[0];

    const anyTier = await listModels({
      status: 'enabled',
      capability,
      configuredOnly: true,
      provider: prefs.provider,
    });
    if (anyTier.length > 0) return sortByCost(anyTier)[0];
  }

  return getBestModelForTier(tier, capability, true);
}

export async function generationRoutes(server: FastifyInstance) {
  // GET /v1/files/list - List files in directory
  server.get('/files/list', async (request, reply) => {
    if (!requireAuthIfEnabled(request, reply)) {
      return;
    }

    const { workspace_id, path: dirPath } = FileListSchema.parse(request.query);

    try {
      // Use the provided path directly. In production, you'd map workspace_id to actual paths
      let fullPath;
      if (dirPath.startsWith('/')) {
        fullPath = dirPath;
      } else {
        // For relative paths, use a default workspace directory
        const workspacePath = `/workspace/${workspace_id}`;
        fullPath = path.join(workspacePath, dirPath);
      }
      const items = await fs.readdir(fullPath, { withFileTypes: true });

      const files = await Promise.all(items.map(async (item) => {
        const itemPath = path.join(fullPath, item.name);
        const stats = await fs.stat(itemPath);

        return {
          name: item.name,
          path: path.join(dirPath, item.name).replace(/^\//, ''),
          is_dir: item.isDirectory(),
          size: stats.size,
          last_modified: stats.mtime.toISOString(),
        };
      }));

      return {
        request_id: crypto.randomUUID(),
        elapsed_ms: 0,
        json: {
          files: files.sort((a, b) => {
            // Directories first, then alphabetically
            if (a.is_dir !== b.is_dir) return b.is_dir ? 1 : -1;
            return a.name.localeCompare(b.name);
          }),
        },
      };
    } catch (err) {
      server.log.error(err);
      return reply.code(500).send({
        error: 'Failed to list files',
        message: err instanceof Error ? err.message : 'Unknown error'
      });
    }
  });

  // GET /v1/files/read - Read file contents
  server.get('/files/read', async (request, reply) => {
    if (!requireAuthIfEnabled(request, reply)) {
      return;
    }

    const { workspace_id, path: filePath } = FileReadSchema.parse(request.query);

    try {
      const workspacePath = `/workspace/${workspace_id}`;
      const fullPath = path.join(workspacePath, filePath);

      const content = await fs.readFile(fullPath, 'utf-8');
      const stats = await fs.stat(fullPath);

      // Detect language from file extension
      const ext = path.extname(filePath).toLowerCase();
      const languageMap: Record<string, string> = {
        '.js': 'javascript',
        '.ts': 'typescript',
        '.py': 'python',
        '.rs': 'rust',
        '.go': 'go',
        '.java': 'java',
        '.cpp': 'cpp',
        '.c': 'c',
        '.h': 'c',
        '.cs': 'csharp',
        '.php': 'php',
        '.rb': 'ruby',
        '.swift': 'swift',
        '.kt': 'kotlin',
        '.scala': 'scala',
        '.md': 'markdown',
        '.json': 'json',
        '.yaml': 'yaml',
        '.yml': 'yaml',
        '.toml': 'toml',
        '.xml': 'xml',
        '.html': 'html',
        '.css': 'css',
        '.sql': 'sql',
        '.sh': 'bash',
        '.bash': 'bash',
        '.fish': 'fish',
        '.zsh': 'zsh',
        '.ps1': 'powershell',
        '.dockerfile': 'dockerfile',
      };

      return {
        request_id: crypto.randomUUID(),
        elapsed_ms: 0,
        json: {
          content,
          language: languageMap[ext] || 'text',
          line_count: content.split('\n').length,
          file_path: filePath,
        },
      };
    } catch (err) {
      server.log.error(err);
      return reply.code(404).send({
        error: 'File not found',
        message: err instanceof Error ? err.message : 'Unknown error'
      });
    }
  });

  // POST /v1/files/write - Write/create file
  server.post('/files/write', async (request, reply) => {
    if (!requireAuthIfEnabled(request, reply)) {
      return;
    }

    const { workspace_id, file_path, content, create_backup } = FileWriteSchema.parse(request.body);

    try {
      const workspacePath = `/workspace/${workspace_id}`;
      const fullPath = path.join(workspacePath, file_path);

      // Create backup if requested and file exists
      let backupPath: string | undefined;
      if (create_backup) {
        try {
          const stats = await fs.stat(fullPath);
          const backupDir = path.join(workspacePath, '.backups');
          await fs.mkdir(backupDir, { recursive: true });

          const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
          const backupFileName = `${path.basename(file_path)}.${timestamp}.bak`;
          backupPath = path.join(backupDir, backupFileName);

          await fs.copyFile(fullPath, backupPath);
        } catch (err) {
          // File doesn't exist, no backup needed
        }
      }

      // Ensure directory exists
      await fs.mkdir(path.dirname(fullPath), { recursive: true });

      // Write file
      await fs.writeFile(fullPath, content, 'utf-8');

      // Get diff if backup exists
      let diff: { old: string; new: string } | undefined;
      if (backupPath) {
        const oldContent = await fs.readFile(backupPath, 'utf-8');
        diff = { old: oldContent, new: content };
      }

      return {
        request_id: crypto.randomUUID(),
        elapsed_ms: 0,
        json: {
          success: true,
          backup_path: backupPath,
          diff,
        },
      };
    } catch (err) {
      server.log.error(err);
      return reply.code(500).send({
        error: 'Failed to write file',
        message: err instanceof Error ? err.message : 'Unknown error'
      });
    }
  });

  // POST /v1/chats/:chatId/run - Start generation (SSE streaming)
  server.post<RunParams>('/chats/:chatId/run', async (request, reply) => {
    const { chatId } = request.params;

    if (!requireAuthIfEnabled(request, reply)) {
      return;
    }

    if (!enforceRateLimitIfEnabled(request, reply, {
      routeKey: 'run',
      maxRequests: env.RATE_LIMIT_RUN_PER_WINDOW,
    })) {
      return;
    }

    const body = RunChatSchema.parse(request.body);

    // Verify chat exists
    const chat = await prisma.chat.findUnique({
      where: { id: chatId },
      include: {
        messages: {
          // Filter out tool messages from DB as defense-in-depth (should not be created by clients)
          where: {
            role: { in: ['user', 'assistant', 'system'] },
          },
          orderBy: { createdAt: 'asc' },
          take: 50, // Last 50 messages for context
        },
        project: true,
        workspace: true,
      },
    });

    if (!chat) {
      return reply.code(404).send({ error: 'Chat not found' });
    }

    // Set up SSE streaming
    reply.raw.setHeader('Content-Type', 'text/event-stream');
    reply.raw.setHeader('Cache-Control', 'no-cache');
    reply.raw.setHeader('Connection', 'keep-alive');

    // Helper to send SSE events
    const sendEvent = (type: string, data: any) => {
      try {
        const dataStr = typeof data === 'string' ? data : JSON.stringify(data);
        reply.raw.write(`event: ${type}\n`);
        reply.raw.write(`data: ${dataStr}\n\n`);
      } catch (e) {
        server.log.error({ err: e, type, data }, 'Failed to send SSE event');
      }
    };

    try {
      // 0. Inject task context if chat has associated tasks
      let taskContext = '';
      try {
        const chatTasks = await prisma.task.findMany({
          where: { chat_id: chatId },
          orderBy: [{ priority: 'desc' }, { created_at: 'desc' }],
          take: 10,
        });

        if (chatTasks.length > 0) {
          const taskLines = chatTasks.map(t => {
            const status = t.status === 'COMPLETED' ? 'DONE' :
                           t.status === 'IN_PROGRESS' ? 'ACTIVE' :
                           t.status === 'CANCELLED' ? 'CANCELLED' : 'PENDING';
            return `  [${status}] P${t.priority} - ${t.title}${t.description ? `: ${t.description}` : ''} (id: ${t.id})`;
          }).join('\n');

          taskContext = `\n## Current Tasks\nThe following tasks are associated with this conversation:\n${taskLines}\n\nUpdate task status as you make progress. Reference tasks by their IDs.\n`;
        }
      } catch (err) {
        server.log.warn({ err }, 'Task context retrieval failed');
      }

      // 0. Get last user message for interpreter + memory retrieval
      let lastUserIndex = -1;
      for (let i = chat.messages.length - 1; i >= 0; i -= 1) {
        if (chat.messages[i].role === 'user') {
          lastUserIndex = i;
          break;
        }
      }
      if (lastUserIndex === -1) {
        throw new Error('No user message found in chat');
      }
      const lastUserMsg = chat.messages[lastUserIndex];

      // 0.5 Classification pass — Codex router or legacy interpreter+triage
      let codexHeader: CodexHeader | null = null;
      let interpretation: Awaited<ReturnType<typeof interpretUserMessage>> | null = null;
      let interpretedUserMessage = lastUserMsg.content;
      let category = 'CHAT_QA';
      let lane: 'quick' | 'standard' | 'deep' = 'standard';
      let complexity = 3;
      let selectionTier = 2;
      let codexRouterUsed = false;

      if (env.CODEX_ROUTER_ENABLED) {
        // --- NEW: Codex Header Routing ---
        sendEvent('status', { message: 'Codex router classification...' });
        try {
          codexHeader = await classifyWithCodex(lastUserMsg.content);
          codexRouterUsed = true;

          // Emit classification event (replaces interpreter.debug)
          sendEvent('codex.classification', {
            intent: codexHeader.intent,
            category: codexHeader.category,
            complexity: codexHeader.complexity,
            lane: codexHeader.lane,
            tier: codexHeader.tier,
            tools: codexHeader.tools,
            context_needs: codexHeader.contextNeeds,
            confidence: codexHeader.confidence,
            reasoning: codexHeader.reasoning,
            safety: codexHeader.safety,
          });

          sendEvent('status', {
            message: `Codex: ${codexHeader.intent} / ${codexHeader.category} (tier ${codexHeader.tier}, ${codexHeader.lane})`,
          });

          // Use header fields directly
          category = codexHeader.category;
          lane = codexHeader.lane;
          complexity = codexHeader.complexity;
          const tierMap = { quick: 1, standard: 2, deep: 3 } as const;
          const requestedTier = tierMap[body.mode];
          const baseTier = body.auto ? codexHeader.tier : requestedTier;
          selectionTier = body.speed ? Math.max(1, baseTier - 1) : baseTier;
        } catch (err) {
          server.log.warn({ err }, 'Codex router failed, falling back to interpreter+triage');
          sendEvent('status', { message: 'Codex router failed, using legacy pipeline...' });
          codexHeader = null;
          codexRouterUsed = false;
        }
      }

      // --- FALLBACK: Legacy interpreter + triage pipeline ---
      if (!codexRouterUsed) {
        sendEvent('status', { message: 'Interpreter pass (Cloudflare)...' });
        interpretation = await interpretUserMessage(lastUserMsg.content);
        interpretedUserMessage = interpretation.normalizedUserMessage || lastUserMsg.content;

        sendEvent('interpreter.debug', {
          raw_message: lastUserMsg.content,
          normalized_message: interpretation.normalizedUserMessage,
          primary_intent: interpretation.primaryIntent,
          intents: interpretation.intents,
          confidence: interpretation.confidence,
          reason: interpretation.reason || null,
          should_clarify: interpretation.shouldClarify,
        });

        sendEvent('status', {
          message: `Interpreter intent: ${interpretation.primaryIntent} (${interpretation.intents.join(', ')})`,
        });

        // Run triage for legacy path
        const triageResult = runTriage({
          user_message: interpretedUserMessage,
          mode: body.mode,
        });

        category = triageResult.decision.category;
        lane = triageResult.decision.lane;
        complexity = triageResult.decision.complexity;

        const tierMap = { quick: 1, standard: 2, deep: 3 } as const;
        const triageTier = tierMap[lane];
        const requestedTier = tierMap[body.mode];
        const baseTier = body.auto ? triageTier : requestedTier;
        selectionTier = body.speed ? Math.max(1, baseTier - 1) : baseTier;
      }

      // --- Early returns (clarify, filesystem) ---
      const effectiveIntent = codexHeader?.intent ?? interpretation?.primaryIntent ?? 'chat';

      if (effectiveIntent === 'clarify') {
        const clarification = codexHeader
          ? codexHeader.reasoning || 'Could you clarify what you need?'
          : interpretation?.clarificationQuestion?.trim() || 'Could you clarify what you need?';

        const assistantMessage = await prisma.message.create({
          data: {
            chatId,
            role: 'assistant',
            content: clarification,
          },
        });

        const updatedAt = new Date();
        await prisma.chat.update({
          where: { id: chatId },
          data: { updatedAt },
        });

        sendEvent('message.final', {
          id: assistantMessage.id,
          role: 'assistant',
          content: clarification,
          provider: codexRouterUsed ? 'azure' : 'cloudflare',
          model: codexRouterUsed ? env.CODEX_ROUTER_MODEL : env.INTERPRETER_MODEL,
          modelDisplayName: codexRouterUsed ? 'Codex Router' : 'Interpreter',
          usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
          codex: codexHeader ? {
            intent: codexHeader.intent,
            category: codexHeader.category,
            confidence: codexHeader.confidence,
          } : undefined,
        });

        sendEvent('chat.updated', {
          id: chatId,
          title: chat.title,
          updatedAt: updatedAt.toISOString(),
        });

        reply.raw.end();
        return;
      }

      // --- Web search (browse intent or codex context_needs) ---
      let webSearchContext = '';
      const needsWebSearch = codexHeader
        ? (codexHeader.intent === 'browse' || codexHeader.contextNeeds.includes('web_search'))
        : (interpretation?.primaryIntent === 'browse');

      if (needsWebSearch) {
        sendEvent('status', { message: 'Running web search...' });
        try {
          const result = await searchWeb(interpretedUserMessage, 5);
          if (result && result.hits.length > 0) {
            webSearchContext = formatWebSearchContext(result);
            sendEvent('status', { message: `Web search returned ${result.hits.length} result(s).` });
          } else {
            sendEvent('status', { message: 'Web search is unavailable or returned no results.' });
          }
        } catch (err) {
          server.log.warn({ err }, 'Web search failed');
          sendEvent('status', { message: 'Web search failed; continuing without external browse context.' });
        }
      }

      // --- Selective memory retrieval ---
      sendEvent('status', { message: 'Retrieving relevant memory...' });

      let memoryContext = '';
      let identityContext = '';
      let chatMemoryContext = '';

      // Determine what context to fetch based on codex header or default behavior
      const needsIdentity = codexHeader
        ? codexHeader.contextNeeds.includes('identity')
        : true; // legacy always fetches
      const needsWorkspaceMemory = codexHeader
        ? (codexHeader.contextNeeds.includes('workspace_memory') || codexHeader.contextNeeds.includes('project_memory'))
        : true;

      try {
        if (env.MEMORY_V2_ENABLED) {
          const memoryPromises: Promise<string>[] = [];

          if (needsIdentity) {
            memoryPromises.push(getIdentityContext(interpretedUserMessage, 3));
          } else {
            memoryPromises.push(Promise.resolve(''));
          }

          if (needsWorkspaceMemory) {
            memoryPromises.push(getChatMemoryContext(interpretedUserMessage, chatId, 5));
          } else {
            memoryPromises.push(Promise.resolve(''));
          }

          [identityContext, chatMemoryContext] = await Promise.all(memoryPromises);
        } else if (needsWorkspaceMemory) {
          memoryContext = await getRelevantContext(
            interpretedUserMessage,
            chat.projectId,
            chat.workspaceId || undefined,
            5,
          );
        }
      } catch (err) {
        server.log.warn({ err }, 'Memory retrieval failed');
      }

      sendEvent('memory.injected', {
        identity_chunks: identityContext ? 1 : 0,
        chat_chunks: chatMemoryContext ? 1 : 0,
        legacy_chunks: memoryContext ? 1 : 0,
        web_search: !!webSearchContext,
        memory_v2_enabled: env.MEMORY_V2_ENABLED,
        codex_selective: codexRouterUsed,
      });

      // --- Model selection ---
      sendEvent('status', {
        message: body.auto
          ? `Routing auto (${category}/${lane}, complexity: ${complexity})...`
          : `Routing manual (${body.mode}, complexity: ${complexity})...`,
      });

      if (body.speed) {
        sendEvent('status', {
          message: 'Speed mode enabled: preferring a faster model tier...',
        });
      }

      const primaryModel = await resolveRequestedModel(selectionTier, 'text', body.model_prefs);
      if (!primaryModel) {
        throw new Error('No models available. Please configure at least one provider.');
      }

      const fallbackPool = await listModels({
        status: 'enabled',
        capability: 'text',
        configuredOnly: true,
      });
      const fallbackCandidates = sortFallbackCandidates(
        fallbackPool.filter((model) => model.id !== primaryModel.id),
        selectionTier,
      );
      const candidateModels = [primaryModel, ...fallbackCandidates];

      // --- Build provider messages ---
      const providerMessages: ProviderMessage[] = [];

      // Inject Codex header as system message so downstream model sees routing metadata
      if (codexHeader) {
        providerMessages.push({
          type: 'message',
          role: 'system',
          content: serializeHeader(codexHeader) + '\nThe above header classifies this request. Use it to guide your response style, depth, and tool usage.',
        });
      }

      if (identityContext) {
        providerMessages.push({
          type: 'message',
          role: 'system',
          content: identityContext,
        });
      }

      if (chatMemoryContext) {
        providerMessages.push({
          type: 'message',
          role: 'system',
          content: chatMemoryContext,
        });
      }

      if (webSearchContext) {
        providerMessages.push({
          type: 'message',
          role: 'system',
          content: webSearchContext,
        });
      }

      if (memoryContext) {
        providerMessages.push({
          type: 'message',
          role: 'system',
          content: memoryContext,
        });
      }

      if (taskContext) {
        providerMessages.push({
          type: 'message',
          role: 'system',
          content: taskContext,
        });
      }

      // Add conversation messages
      providerMessages.push(
        ...chat.messages.map((m: { role: string; content: string }, idx: number) => {
          if (m.role === 'tool') {
            return {
              type: 'message' as const,
              role: 'assistant' as const,
              content: `[Tool Result]\n${m.content}`,
            };
          }
          return {
            type: 'message' as const,
            role: m.role as 'user' | 'assistant' | 'system',
            content: idx === lastUserIndex ? interpretedUserMessage : m.content,
          };
        }),
      );

      // --- Selective tool injection ---
      // Codex header tells us exactly which tools to enable; legacy enables all
      const shouldUseTools = env.TOOLS_ENABLED && toolRegistry.getAll().length > 0;
      const activeTools = (codexHeader && codexHeader.tools.length > 0)
        ? getToolsByNames(codexHeader.tools)
        : (shouldUseTools ? toolRegistry.getAll() : []);
      const toolsEnabled = shouldUseTools && activeTools.length > 0;
      const maxToolIterations = 5;
      let toolIterations = 0;
      let continueWithTools = true;

      // Pre-declare for both paths
      let fullResponse = '';
      let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };

      // --- DeepSeek Orchestrator Mode ---
      // Using orchestrator for text-based tool parsing
      const isDeepSeek = primaryModel?.id.includes('deepseek');
      const useOrchestrator = isDeepSeek;

      if (useOrchestrator) {
        sendEvent('status', { message: 'Using DeepSeek orchestrator mode...' });

        // Build orchestrator messages from the conversation
        const orchMessages = providerMessages.slice(1).map(m => ({
          role: m.role as 'user' | 'assistant',
          content: m.content || '',
        }));

        // Create and run the orchestrator
        const orchestrator = new DeepSeekOrchestrator(
          `${primaryModel.provider}:${primaryModel.deploymentName}`,
          { maxIterations: 5, includeReasoning: true }
        );

        const orchResult = await orchestrator.run(
          lastUserMsg.content,
          orchMessages,
          { projectId: chat.projectId, workspaceId: chat.workspaceId || undefined }
        );

        // Stream the response back
        sendEvent('message.start', {});
        for (const char of orchResult.response) {
          sendEvent('token.delta', { text: char });
          fullResponse += char;
        }
        sendEvent('message.stop', {});

        // Send tool results as metadata
        if (orchResult.toolResults.length > 0) {
          sendEvent('tools.executed', {
            tools: orchResult.toolResults.map(t => ({
              name: t.tool,
              success: t.success,
              durationMs: t.durationMs,
            })),
          });
        }

        // Save assistant message
        await prisma.message.create({
          data: {
            chatId,
            role: 'assistant',
            content: fullResponse,
          },
        });

        sendEvent('inference.complete', {
          model: primaryModel.displayName,
          provider: primaryModel.provider,
          tokens: usage,
        });

        reply.raw.end();
        return reply;
      }

      // 6a. Tool execution loop - multi-turn agentic system
      // fullResponse and usage already declared above
      let selectedModel: ModelDefinition | null = null;
      let lastProviderError: unknown = null;
      const blockedProviders = new Set<string>();

      while (continueWithTools && toolIterations < maxToolIterations) {
        toolIterations++;

        // Prepare tool definitions for this iteration (selective based on codex header)
        const toolDefinitions = toolsEnabled
          ? activeTools.map(tool => ({
              type: 'function' as const,
              function: {
                name: tool.name,
                description: tool.description,
                parameters: {
                  type: 'object' as const,
                  properties: Object.fromEntries(
                    tool.parameters.map(p => [p.name, {
                      type: p.type,
                      description: p.description,
                      ...(p.enum ? { enum: p.enum } : {}),
                      ...(p.default !== undefined ? { default: p.default } : {}),
                    }]),
                  ),
                  required: tool.parameters.filter(p => p.required).map(p => p.name),
                },
              },
            }))
          : undefined;

        // Try each model candidate for this iteration
        for (let candidateIndex = 0; candidateIndex < candidateModels.length; candidateIndex++) {
          const candidate = candidateModels[candidateIndex];
          if (blockedProviders.has(candidate.provider)) {
            continue;
          }

          if (toolIterations === 1) {
            sendEvent('status', {
              message: `Using ${candidate.displayName} (${candidate.provider})...`,
            });
          } else {
            sendEvent('status', {
              message: `Continuing with ${candidate.displayName} (tool iteration ${toolIterations})...`,
            });
          }

          try {
            const provider = getProvider(candidate.provider);
            fullResponse = '';
            usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
            let toolCalls: ToolCall[] = [];
            let finishReason: string | undefined;

            // Stream from LLM
            // DeepSeek R1 doesn't support function calling on Azure - use text parsing instead
            const modelSupportsTools = candidate.capabilities.includes('tools') && !candidate.id.includes('deepseek');
            const toolsToUse = modelSupportsTools ? toolDefinitions : undefined;
            for await (const chunk of provider.sendChatStream(providerMessages, {
              model: candidate.deploymentName,
              maxTokens: body.speed
                ? Math.min(candidate.maxOutputTokens, 1024)
                : candidate.maxOutputTokens,
              temperature: 0.7,
              tools: toolsToUse,
              tool_choice: toolsEnabled && modelSupportsTools ? 'auto' : undefined,
            })) {
              if (chunk.text) {
                // Handle reasoning/thinking content (DeepSeek R1)
                if (chunk.reasoning) {
                  fullResponse += `<thinking>${chunk.text}</thinking>`;
                  sendEvent('token.delta', { text: chunk.text, reasoning: true });
                } else {
                  fullResponse += chunk.text;
                  sendEvent('token.delta', { text: chunk.text });
                }
              }

              if (chunk.tool_calls) {
                toolCalls = chunk.tool_calls;
              }

              if (chunk.finish_reason) {
                finishReason = chunk.finish_reason;
              }

              if (chunk.usage) {
                usage = chunk.usage;
              }
            }

            if (!fullResponse.trim() && toolCalls.length === 0) {
              throw new Error(`Model "${candidate.displayName}" returned an empty response`);
            }

            selectedModel = candidate;

            // Check if we need to execute tools
            if (finishReason === 'tool_calls' && toolCalls.length > 0) {
              // Add assistant message with tool calls to conversation
              providerMessages.push({
                type: 'message',
                role: 'assistant',
                content: fullResponse || '',
                tool_calls: toolCalls,
              });

              // Execute each tool
              for (const toolCall of toolCalls) {
                const toolStartTime = Date.now();

                // Parse and log tool arguments
                let parsedArgs: any = {};
                try {
                  parsedArgs = JSON.parse(toolCall.arguments);
                } catch (e) {
                  // Leave as empty object if parsing fails
                }

                sendEvent('tool.arguments', {
                  tool_call_id: toolCall.id,
                  name: toolCall.name,
                  arguments: parsedArgs,
                });

                sendEvent('tool.start', {
                  tool_call_id: toolCall.id,
                  tool_name: toolCall.name,
                  arguments: toolCall.arguments,
                });

                const tool = toolRegistry.get(toolCall.name);
                if (!tool) {
                  const errorResult = JSON.stringify({ error: 'Tool not found' });

                  providerMessages.push({
                    type: 'message',
                    role: 'tool',
                    tool_call_id: toolCall.id,
                    name: toolCall.name,
                    content: errorResult,
                  });

                  sendEvent('tool.end', {
                    tool_call_id: toolCall.id,
                    tool_name: toolCall.name,
                    success: false,
                    error: 'Tool not found',
                  });

                  continue;
                }

                try {
                  const args = JSON.parse(toolCall.arguments);
                  const result = await tool.execute(args);
                  const toolDurationMs = Date.now() - toolStartTime;

                  // Add tool result to provider messages
                  providerMessages.push({
                    type: 'message',
                    role: 'tool',
                    tool_call_id: toolCall.id,
                    name: toolCall.name,
                    content: result.content,
                  });

                  sendEvent('tool.end', {
                    tool_call_id: toolCall.id,
                    tool_name: toolCall.name,
                    success: result.success,
                    duration_ms: toolDurationMs,
                    preview: result.content.slice(0, 200),
                  });
                } catch (error) {
                  const errorMessage = error instanceof Error ? error.message : String(error);

                  providerMessages.push({
                    type: 'message',
                    role: 'tool',
                    tool_call_id: toolCall.id,
                    name: toolCall.name,
                    content: JSON.stringify({ error: errorMessage }),
                  });

                  sendEvent('tool.end', {
                    tool_call_id: toolCall.id,
                    tool_name: toolCall.name,
                    success: false,
                    error: errorMessage,
                  });
                }
              }

              // Continue loop to get final response from LLM
              continue;
            }

            // No tool calls or reached stop - exit loop and save response
            continueWithTools = false;
            break;
          } catch (err) {
            lastProviderError = err;
            const errorMessage = err instanceof Error ? err.message : String(err);
            const isAuthError = /(googleautherror|invalid authentication|unauthorized|no route for that uri|not configured|api key|403|401)/i.test(errorMessage);

            if (isAuthError) {
              blockedProviders.add(candidate.provider);
            }

            server.log.warn(
              { err, provider: candidate.provider, model: candidate.deploymentName },
              'Model run failed, trying fallback',
            );

            // Structured provider error event
            sendEvent('error', {
              type: 'provider_failure',
              provider: candidate.provider,
              model: candidate.deploymentName,
              error_message: errorMessage,
              is_auth_error: isAuthError,
              blocked: isAuthError,
              retrying: candidateIndex < candidateModels.length - 1,
              fallback_available: candidateIndex < candidateModels.length - 1,
            });

            sendEvent('status', {
              message: `${candidate.displayName} unavailable, trying fallback...`,
            });
          }
        }

        if (!selectedModel && toolIterations === 1) {
          throw (
            lastProviderError instanceof Error
              ? lastProviderError
              : new Error('All configured models failed to respond')
          );
        }

        if (!continueWithTools) {
          break;
        }
      }

      if (!selectedModel) {
        throw (
          lastProviderError instanceof Error
            ? lastProviderError
            : new Error('All configured models failed to respond')
        );
      }

      // 7. Strip any header artifacts from response before persisting
      const cleanResponse = stripHeader(fullResponse);

      // 8. Save assistant message
      const assistantMessage = await prisma.message.create({
        data: {
          chatId,
          role: 'assistant',
          content: cleanResponse,
        },
      });

      // 9. Update chat title if needed
      const newTitle = chat.title === 'New Chat'
        ? interpretedUserMessage.slice(0, 50) + (interpretedUserMessage.length > 50 ? '...' : '')
        : chat.title;

      const updatedAt = new Date();

      await prisma.chat.update({
        where: { id: chatId },
        data: {
          updatedAt,
          title: newTitle,
        },
      });

      // 10. Send final event
      sendEvent('message.final', {
        id: assistantMessage.id,
        role: 'assistant',
        content: cleanResponse,
        provider: selectedModel.provider,
        model: selectedModel.deploymentName,
        modelDisplayName: selectedModel.displayName,
        usage: {
          promptTokens: usage.promptTokens,
          completionTokens: usage.completionTokens,
          totalTokens: usage.totalTokens,
        },
        codex: codexHeader ? {
          intent: codexHeader.intent,
          category: codexHeader.category,
          complexity: codexHeader.complexity,
          lane: codexHeader.lane,
          tier: codexHeader.tier,
          tools: codexHeader.tools,
          confidence: codexHeader.confidence,
          reasoning: codexHeader.reasoning,
        } : undefined,
        interpreter: interpretation ? {
          action: 'execute',
          primary_intent: interpretation.primaryIntent,
          intents: interpretation.intents,
          confidence: interpretation.confidence,
          reason: interpretation.reason || null,
        } : undefined,
        triage: {
          category,
          lane,
          complexity,
        },
      });

      sendEvent('chat.updated', {
        id: chatId,
        title: newTitle,
        updatedAt: updatedAt.toISOString(),
      });

      reply.raw.end();
    } catch (err) {
      server.log.error(err);
      sendEvent('error', {
        message: err instanceof Error ? err.message : 'Unknown error',
        fatal: true,
      });
      reply.raw.end();
    }
  });

  // POST /v1/completion - Fast inline code completion
  server.post('/completion', async (request, reply) => {
    if (!requireAuthIfEnabled(request, reply)) {
      return;
    }

    if (!enforceRateLimitIfEnabled(request, reply, {
      routeKey: 'completion',
      maxRequests: env.RATE_LIMIT_COMPLETION_PER_WINDOW,
    })) {
      return;
    }

    const body = CompletionSchema.parse(request.body);

    // Determine language from file extension if not provided
    let language = body.language;
    if (!language) {
      const ext = body.file_path.split('.').pop()?.toLowerCase();
      language = ext || 'text';
    }

    // Build completion prompt
    const { line, column } = body.cursor_position;
    const currentLine = body.content.split('\n')[line] || '';
    const prefix = currentLine.slice(0, column);
    const suffix = currentLine.slice(column);

    const context = {
      file_path: body.file_path,
      language,
      prefix,
      suffix,
      before_lines: body.surrounding_lines.before,
      after_lines: body.surrounding_lines.after,
      max_suggestions: body.max_suggestions,
    };

    // Use fast model for completion (tier 1)
    const completionModel = await resolveRequestedModel(1, 'text');
    if (!completionModel) {
      return reply.code(404).send({ error: 'No models available for completion' });
    }

    try {
      const provider = getProvider(completionModel.provider);
      const suggestions: Array<{
        text: string;
        confidence: number;
        type: 'completion' | 'refactor' | 'fix';
        position?: {
          start_line: number;
          start_col: number;
          end_line: number;
          end_col: number;
        };
      }> = [];

      // Quick completion prompt
      const completionPrompt = `You are an AI code completion assistant.

File: ${body.file_path}
Language: ${language}

Context (lines before):
${body.surrounding_lines.before.map((l, i) => `${line - body.surrounding_lines.before.length + i + 1}: ${l}`).join('\n')}

Current line ${line + 1}: ${currentLine}
${' '.repeat(column)}^

Context (lines after):
${body.surrounding_lines.after.map((l, i) => `${line + i + 2}: ${l}`).join('\n')}

Provide up to ${body.max_suggestions} code completion suggestions for the cursor position.
Each suggestion should:
1. Complete the current line or provide relevant code
2. Be syntactically correct for ${language}
3. Match the surrounding code style
4. Be useful and relevant to the context

Format each suggestion as JSON:
{
  "text": "the completed code snippet",
  "confidence": 0.8,
  "type": "completion"
}

Respond with only the JSON array of suggestions, nothing else.`;

      // Get completion
      const startTime = Date.now();
      let completionText = '';

      for await (const chunk of provider.sendChatStream([{
        role: 'user',
        content: completionPrompt,
      }], {
        model: completionModel.deploymentName,
        maxTokens: 200,
        temperature: 0.3, // Lower temperature for more deterministic completions
      })) {
        if (chunk.text) {
          completionText += chunk.text;
        }
      }

      // Parse JSON response
      try {
        const suggestionArray = JSON.parse(completionText);
        for (const item of suggestionArray) {
          if (item.text && typeof item.text === 'string') {
            suggestions.push({
              text: item.text,
              confidence: item.confidence || 0.5,
              type: item.type || 'completion',
            });
          }
        }
      } catch (e) {
        // Fallback to simple completion if JSON parsing fails
        const fallbackSuggestion = prefix + ' ' + (language === 'javascript' ? '// Complete here' : '# Complete here');
        suggestions.push({
          text: fallbackSuggestion,
          confidence: 0.3,
          type: 'completion',
        });
      }

      const elapsed = Date.now() - startTime;

      // Return suggestions
      return {
        request_id: crypto.randomUUID(),
        elapsed_ms: elapsed,
        json: {
          suggestions: suggestions.slice(0, body.max_suggestions),
          model_used: completionModel.displayName,
          language,
          file_path: body.file_path,
          latency_ms: elapsed,
        },
      };
    } catch (err) {
      server.log.error(err);
      return reply.code(500).send({
        error: 'Completion failed',
        message: err instanceof Error ? err.message : 'Unknown error'
      });
    }
  });

  // POST /v1/chats/:chatId/cancel - Cancel ongoing generation
  server.post<RunParams>('/chats/:chatId/cancel', async (request, reply) => {
    const { chatId } = request.params;

    // TODO: Implement cancellation logic with AbortController
    // For now, just return success
    return { ok: true, message: 'Cancellation not yet implemented' };
  });
}

projects.ts

Path: starbot_api/src/routes/projects.ts

Size: 2935 bytes

// Project routes
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';

const CreateProjectSchema = z.object({
  name: z.string().min(1).max(255),
  email: z.string().email().optional(),
});

const UpdateProjectSchema = z.object({
  name: z.string().min(1).max(255).optional(),
});

export async function projectRoutes(server: FastifyInstance) {
  // GET /v1/projects - List projects (filtered by email - required)
  server.get<{ Querystring: { email: string } }>('/projects', async (request, reply) => {
    const { email } = request.query;

    if (!email) {
      return reply.code(400).send({ error: 'Email is required' });
    }

    const projects = await prisma.project.findMany({
      where: { email },
      orderBy: { createdAt: 'desc' },
      include: {
        _count: {
          select: { chats: true },
        },
      },
    });

    return { projects };
  });

  // POST /v1/projects - Create a new project
  server.post('/projects', async (request, reply) => {
    const parsed = CreateProjectSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({
        error: 'Invalid request body',
      });
    }
    const body = parsed.data;

    const project = await prisma.project.create({
      data: {
        name: body.name,
        email: body.email,
      },
    });

    return reply.code(201).send({ project });
  });

  // GET /v1/projects/:id - Get a specific project
  server.get<{ Params: { id: string } }>('/projects/:id', async (request, reply) => {
    const { id } = request.params;

    const project = await prisma.project.findUnique({
      where: { id },
      include: {
        chats: {
          orderBy: { updatedAt: 'desc' },
          take: 10,
        },
      },
    });

    if (!project) {
      return reply.code(404).send({ error: 'Project not found' });
    }

    return { project };
  });

  // PUT /v1/projects/:id - Update a project
  server.put<{ Params: { id: string } }>('/projects/:id', async (request, reply) => {
    const { id } = request.params;
    const parsed = UpdateProjectSchema.safeParse(request.body);
    if (!parsed.success) {
      return reply.code(400).send({
        error: 'Invalid request body',
      });
    }
    const body = parsed.data;

    try {
      const project = await prisma.project.update({
        where: { id },
        data: body,
      });
      return { project };
    } catch (err) {
      return reply.code(404).send({ error: 'Project not found' });
    }
  });

  // DELETE /v1/projects/:id - Delete a project
  server.delete<{ Params: { id: string } }>('/projects/:id', async (request, reply) => {
    const { id } = request.params;

    try {
      await prisma.project.delete({
        where: { id },
      });
      return { ok: true };
    } catch (err) {
      return reply.code(404).send({ error: 'Project not found' });
    }
  });
}

messages.ts

Path: starbot_api/src/routes/messages.ts

Size: 6556 bytes

// Message routes
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { requireAuthIfEnabled, enforceRateLimitIfEnabled } from '../security/route-guards.js';
import { env } from '../env.js';

// Client-facing schema: only allow user and assistant messages
const CreateMessageSchema = z.object({
  role: z.enum(['user', 'assistant']),
  content: z.string().min(1),
});

// Internal-only schema for tool and system messages (used by generation.ts)
export const InternalMessageSchema = z.object({
  role: z.enum(['user', 'assistant', 'system', 'tool']),
  content: z.string().min(1),
});

const UpdateMessageSchema = z.object({
  content: z.string().min(1),
});

export async function messageRoutes(server: FastifyInstance) {
  // GET /v1/chats/:chatId/messages - List messages in a chat
  server.get<{ Params: { chatId: string } }>(
    '/chats/:chatId/messages',
    async (request, reply) => {
      const { chatId } = request.params;

      const messages = await prisma.message.findMany({
        where: { chatId },
        orderBy: { createdAt: 'asc' },
      });

      return { messages };
    }
  );

  // POST /v1/chats/:chatId/messages - Add a message to a chat
  server.post<{ Params: { chatId: string } }>(
    '/chats/:chatId/messages',
    async (request, reply) => {
      // Authentication check
      if (!requireAuthIfEnabled(request, reply)) {
        return;
      }

      if (!enforceRateLimitIfEnabled(request, reply, {
        routeKey: 'messages',
        maxRequests: env.RATE_LIMIT_MESSAGES_PER_WINDOW,
      })) {
        return;
      }

      const { chatId } = request.params;
      const rawBody = request.body;
      let body;
      try {
        body = CreateMessageSchema.parse(rawBody);
      } catch (error) {
        console.error('[POST /messages] Validation error:', { error, rawBody });
        if (error instanceof z.ZodError) {
          return reply.code(400).send({ error: 'Invalid request body', details: error.errors });
        }
        throw error;
      }

      // Verify chat exists
      const chat = await prisma.chat.findUnique({
        where: { id: chatId },
      });

      if (!chat) {
        return reply.code(404).send({ error: 'Chat not found' });
      }

      const message = await prisma.message.create({
        data: {
          chatId,
          role: body.role,
          content: body.content,
        },
      });

      // Update chat's updatedAt
      await prisma.chat.update({
        where: { id: chatId },
        data: { updatedAt: new Date() },
      });

      return reply.code(201).send({ message });
    }
  );

  // PUT /v1/messages/:id - Update an existing message
  server.put<{ Params: { id: string } }>(
    '/messages/:id',
    async (request, reply) => {
      if (!requireAuthIfEnabled(request, reply)) {
        return;
      }

      if (!enforceRateLimitIfEnabled(request, reply, {
        routeKey: 'messages',
        maxRequests: env.RATE_LIMIT_MESSAGES_PER_WINDOW,
      })) {
        return;
      }

      const { id } = request.params;
      const body = UpdateMessageSchema.parse(request.body);

      const existingMessage = await prisma.message.findUnique({
        where: { id },
        select: { id: true, chatId: true },
      });

      if (!existingMessage) {
        return reply.code(404).send({ error: 'Message not found' });
      }

      const updatedAt = new Date();
      const [message] = await prisma.$transaction([
        prisma.message.update({
          where: { id },
          data: {
            content: body.content,
          },
        }),
        prisma.chat.update({
          where: { id: existingMessage.chatId },
          data: { updatedAt },
        }),
      ]);

      return { message };
    }
  );

  // DELETE /v1/messages/:id - Delete a single message
  server.delete<{ Params: { id: string } }>(
    '/messages/:id',
    async (request, reply) => {
      if (!requireAuthIfEnabled(request, reply)) {
        return;
      }

      if (!enforceRateLimitIfEnabled(request, reply, {
        routeKey: 'messages',
        maxRequests: env.RATE_LIMIT_MESSAGES_PER_WINDOW,
      })) {
        return;
      }

      const { id } = request.params;

      const existingMessage = await prisma.message.findUnique({
        where: { id },
        select: { id: true, chatId: true },
      });

      if (!existingMessage) {
        return reply.code(404).send({ error: 'Message not found' });
      }

      const updatedAt = new Date();
      await prisma.$transaction([
        prisma.message.delete({
          where: { id },
        }),
        prisma.chat.update({
          where: { id: existingMessage.chatId },
          data: { updatedAt },
        }),
      ]);

      return { ok: true };
    }
  );

  // DELETE /v1/chats/:chatId/messages/after/:messageId - Delete this message and all subsequent messages
  server.delete<{ Params: { chatId: string; messageId: string } }>(
    '/chats/:chatId/messages/after/:messageId',
    async (request, reply) => {
      if (!requireAuthIfEnabled(request, reply)) {
        return;
      }

      if (!enforceRateLimitIfEnabled(request, reply, {
        routeKey: 'messages',
        maxRequests: env.RATE_LIMIT_MESSAGES_PER_WINDOW,
      })) {
        return;
      }

      const { chatId, messageId } = request.params;

      const target = await prisma.message.findUnique({
        where: { id: messageId },
        select: { id: true, chatId: true },
      });

      if (!target || target.chatId !== chatId) {
        return reply.code(404).send({ error: 'Message not found in chat' });
      }

      const orderedMessages = await prisma.message.findMany({
        where: { chatId },
        orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
        select: { id: true },
      });

      const startIndex = orderedMessages.findIndex((m: { id: string }) => m.id === messageId);
      if (startIndex < 0) {
        return reply.code(404).send({ error: 'Message not found in chat' });
      }

      const idsToDelete = orderedMessages
        .slice(startIndex)
        .map((m: { id: string }) => m.id);
      const updatedAt = new Date();

      const [deleteResult] = await prisma.$transaction([
        prisma.message.deleteMany({
          where: { id: { in: idsToDelete } },
        }),
        prisma.chat.update({
          where: { id: chatId },
          data: { updatedAt },
        }),
      ]);

      return {
        ok: true,
        deleted: deleteResult.count,
      };
    }
  );
}

tasks.ts

Path: starbot_api/src/routes/tasks.ts

Size: 11805 bytes

import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { TaskManager } from '../services/task-manager/index.js';

const taskManager = new TaskManager();

// Task creation schema
const CreateTaskSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().optional(),
  priority: z.number().min(0).max(10).optional().default(0),
  chat_id: z.string().optional(),
  parent_id: z.string().optional(),
  metadata: z.any().optional(),
});

// Task update schema
const UpdateTaskSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  description: z.string().optional(),
  status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']).optional(),
  priority: z.number().min(0).max(10).optional(),
  metadata: z.any().optional(),
});

// Task filters schema
const ListTasksSchema = z.object({
  status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']).optional(),
  priority: z.number().min(0).max(10).optional(),
  chat_id: z.string().optional(),
  parent_id: z.string().optional(),
  created_after: z.string().optional(),
  created_before: z.string().optional(),
  page: z.number().min(1).optional().default(1),
  limit: z.number().min(1).max(100).optional().default(20),
}).strict();

export const tasksRoutes = async (fastify: FastifyInstance) => {
  // Get all tasks
  fastify.get('/tasks', {
    handler: async (request, reply) => {
      const {
        status,
        priority,
        chat_id,
        parent_id,
        created_after,
        created_before,
        page = 1,
        limit = 20,
      } = request.query as any;

      try {
        const filters = {
          status,
          priority,
          chat_id,
          parent_id,
          createdAfter: created_after ? new Date(created_after) : undefined,
          createdBefore: created_before ? new Date(created_before) : undefined,
        };

        const result = await taskManager.listTasks(filters);

        if (!result.success) {
          reply.code(400).send(result);
          return;
        }

        // Calculate pagination
        const totalCount = await prisma.task.count({
          where: {
            status: status ? { equals: status } : undefined,
            priority: priority !== undefined ? { equals: priority } : undefined,
            chat_id: chat_id ? { equals: chat_id } : undefined,
            parent_id: parent_id ? { equals: parent_id } : undefined,
            created_at: {
              gte: created_after ? new Date(created_after) : undefined,
              lte: created_before ? new Date(created_before) : undefined,
            },
          },
        });

        const offset = (page - 1) * limit;

        const tasks = result.tasks || [];
        const paginatedTasks = tasks.slice(offset, offset + limit);

        reply.code(200).send({
          success: true,
          data: paginatedTasks,
          pagination: {
            page,
            limit,
            total: totalCount,
          },
        });
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to list tasks',
        });
      }
    },
  });

  // Get task by ID
  fastify.get('/tasks/:id', {
    handler: async (request: any, reply) => {
      const { id } = request.params as { id: string };

      try {
        const result = await taskManager.getTaskById(id);

        if (!result.success) {
          reply.code(404).send(result);
          return;
        }

        reply.code(200).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to get task',
        });
      }
    },
  });

  // Create task
  fastify.post('/tasks', {
    handler: async (request, reply) => {
      try {
        const parsed = CreateTaskSchema.safeParse(request.body);
        if (!parsed.success) {
          reply.code(400).send({
            success: false,
            error: 'Validation failed',
            details: parsed.error.issues,
          });
          return;
        }
        const taskData = parsed.data;

        const result = await taskManager.createTask(taskData);

        if (!result.success) {
          reply.code(400).send(result);
          return;
        }

        reply.code(201).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to create task',
        });
      }
    },
  });

  // Update task
  fastify.put('/tasks/:id', {
    handler: async (request: any, reply) => {
      const { id } = request.params as { id: string };

      try {
        const parsed = UpdateTaskSchema.safeParse(request.body);
        if (!parsed.success) {
          reply.code(400).send({
            success: false,
            error: 'Validation failed',
            details: parsed.error.issues,
          });
          return;
        }
        const updateData = parsed.data;

        const result = await taskManager.updateTask(id, updateData);

        if (!result.success) {
          reply.code(400).send(result);
          return;
        }

        reply.code(200).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to update task',
        });
      }
    },
  });

  // Delete task
  fastify.delete('/tasks/:id', {
    handler: async (request: any, reply) => {
      const { id } = request.params as { id: string };

      try {
        const result = await taskManager.deleteTask(id);

        if (!result.success) {
          reply.code(400).send(result);
          return;
        }

        reply.code(200).send({
          success: true,
          message: 'Task deleted successfully',
        });
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to delete task',
        });
      }
    },
  });

  // Task action endpoints
  fastify.post('/tasks/:id/start', {
    handler: async (request: any, reply) => {
      const { id } = request.params as { id: string };

      try {
        const result = await taskManager.startTask(id);

        if (!result.success) {
          reply.code(400).send(result);
          return;
        }

        reply.code(200).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to start task',
        });
      }
    },
  });

  fastify.post('/tasks/:id/complete', {
    handler: async (request: any, reply) => {
      const { id } = request.params as { id: string };

      try {
        const result = await taskManager.completeTask(id);

        if (!result.success) {
          reply.code(400).send(result);
          return;
        }

        reply.code(200).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to complete task',
        });
      }
    },
  });

  fastify.post('/tasks/:id/cancel', {
    handler: async (request: any, reply) => {
      const { id } = request.params as { id: string };

      try {
        const result = await taskManager.cancelTask(id);

        if (!result.success) {
          reply.code(400).send(result);
          return;
        }

        reply.code(200).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to cancel task',
        });
      }
    },
  });

  // Create subtask
  fastify.post('/tasks/:id/subtasks', {
    handler: async (request: any, reply) => {
      const { id } = request.params as { id: string };
      const subtaskData = request.body as any;

      try {
        const result = await taskManager.createSubtask(id, subtaskData);

        if (!result.success) {
          reply.code(400).send(result);
          return;
        }

        reply.code(201).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to create subtask',
        });
      }
    },
  });

  // Get task hierarchy
  fastify.get('/tasks/:id/hierarchy', {
    handler: async (request: any, reply) => {
      const { id } = request.params as { id: string };

      try {
        const result = await taskManager.getTaskHierarchy(id);

        if (!result.success) {
          reply.code(404).send(result);
          return;
        }

        reply.code(200).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to get task hierarchy',
        });
      }
    },
  });

  // Add dependencies to a task
  fastify.post('/tasks/:taskId/dependencies', {
    handler: async (request, reply) => {
      const { taskId } = request.params as { taskId: string };
      const { dependencies } = request.body as { dependencies: string[] };

      try {
        const result = await taskManager.addDependencies(taskId, dependencies);

        reply.code(result.success ? 200 : 404).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to add dependencies',
        });
      }
    },
  });

  // Remove dependencies from a task
  fastify.delete('/tasks/:taskId/dependencies', {
    handler: async (request, reply) => {
      const { taskId } = request.params as { taskId: string };
      const { dependencies } = request.body as { dependencies: string[] };

      try {
        const result = await taskManager.removeDependencies(taskId, dependencies);

        reply.code(200).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to remove dependencies',
        });
      }
    },
  });

  // Get task dependencies
  fastify.get('/tasks/:taskId/dependencies', {
    handler: async (request, reply) => {
      const { taskId } = request.params as { taskId: string };

      try {
        const result = await taskManager.getTaskDependencies(taskId);

        reply.code(200).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to get dependencies',
        });
      }
    },
  });

  // Get task dependents
  fastify.get('/tasks/:taskId/dependents', {
    handler: async (request, reply) => {
      const { taskId } = request.params as { taskId: string };

      try {
        const result = await taskManager.getTaskDependents(taskId);

        reply.code(200).send(result);
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to get dependents',
        });
      }
    },
  });

  // Get task analytics
  fastify.get('/tasks/analytics', {
    handler: async (request, reply) => {
      const { chat_id } = request.query as { chat_id?: string };

      try {
        const analytics = await taskManager.getTaskAnalytics(chat_id);

        reply.code(200).send({
          success: true,
          data: analytics,
        });
      } catch (error) {
        reply.code(500).send({
          success: false,
          error: error instanceof Error ? error.message : 'Failed to get task analytics',
        });
      }
    },
  });
};

folders.ts

Path: starbot_api/src/routes/folders.ts

Size: 3257 bytes

// Folder routes
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';

const CreateFolderSchema = z.object({
  name: z.string().min(1).max(255),
});

const UpdateFolderSchema = z.object({
  name: z.string().min(1).max(255).optional(),
});

export async function folderRoutes(server: FastifyInstance) {
  // GET /v1/projects/:projectId/folders - List folders in a project
  server.get<{ Params: { projectId: string } }>(
    '/projects/:projectId/folders',
    async (request, reply) => {
      const { projectId } = request.params;

      const folders = await prisma.folder.findMany({
        where: { projectId },
        orderBy: { createdAt: 'asc' },
        include: {
          chats: {
            select: { id: true, title: true },
            orderBy: { updatedAt: 'desc' },
          },
        },
      });

      return { folders };
    }
  );

  // POST /v1/projects/:projectId/folders - Create a new folder
  server.post<{ Params: { projectId: string } }>(
    '/projects/:projectId/folders',
    async (request, reply) => {
      const { projectId } = request.params;
      const body = CreateFolderSchema.parse(request.body);

      // Verify project exists
      const project = await prisma.project.findUnique({
        where: { id: projectId },
      });

      if (!project) {
        return reply.code(404).send({ error: 'Project not found' });
      }

      const folder = await prisma.folder.create({
        data: {
          projectId,
          name: body.name,
        },
      });

      return reply.code(201).send({ folder });
    }
  );

  // GET /v1/folders/:id - Get a specific folder with its chats
  server.get<{ Params: { id: string } }>('/folders/:id', async (request, reply) => {
    const { id } = request.params;

    const folder = await prisma.folder.findUnique({
      where: { id },
      include: {
        chats: {
          orderBy: { updatedAt: 'desc' },
          include: {
            _count: {
              select: { messages: true },
            },
          },
        },
      },
    });

    if (!folder) {
      return reply.code(404).send({ error: 'Folder not found' });
    }

    return { folder };
  });

  // PUT /v1/folders/:id - Update a folder
  server.put<{ Params: { id: string } }>('/folders/:id', async (request, reply) => {
    const { id } = request.params;
    const body = UpdateFolderSchema.parse(request.body);

    try {
      const folder = await prisma.folder.update({
        where: { id },
        data: body,
      });
      return { folder };
    } catch (err) {
      return reply.code(404).send({ error: 'Folder not found' });
    }
  });

  // DELETE /v1/folders/:id - Delete a folder (moves chats to unfoldered)
  server.delete<{ Params: { id: string } }>('/folders/:id', async (request, reply) => {
    const { id } = request.params;

    // First, move all chats in this folder to have null folderId
    await prisma.chat.updateMany({
      where: { folderId: id },
      data: { folderId: null },
    });

    try {
      await prisma.folder.delete({
        where: { id },
      });
      return { ok: true };
    } catch (err) {
      return reply.code(404).send({ error: 'Folder not found' });
    }
  });
}

auth.ts

Path: starbot_api/src/routes/auth.ts

Size: 8657 bytes

import { FastifyPluginAsync } from 'fastify';
import crypto from 'crypto';
import { prisma } from '../db.js';

// Simple password hashing (use bcrypt in production)
function hashPassword(password: string): string {
  const salt = crypto.randomBytes(16).toString('hex');
  const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
  return `${salt}:${hash}`;
}

function verifyPassword(password: string, stored: string): boolean {
  const [salt, hash] = stored.split(':');
  const verifyHash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex');
  return hash === verifyHash;
}

// Generate JWT-like token (use proper JWT in production)
function generateToken(): string {
  return crypto.randomBytes(32).toString('hex');
}

// In-memory storage for device codes (STUB - use Redis in production)
interface DeviceAuthRequest {
  device_code: string;
  user_code: string;
  verification_url: string;
  expires_at: number;
  status: 'pending' | 'authorized' | 'denied' | 'expired';
  access_token?: string;
}

const deviceRequests = new Map<string, DeviceAuthRequest>();

// Helper to generate random codes
function generateCode(length: number): string {
  return crypto.randomBytes(length).toString('hex').slice(0, length).toUpperCase();
}

// Helper to generate user-friendly code
function generateUserCode(): string {
  const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Exclude confusing chars
  let code = '';
  for (let i = 0; i < 6; i++) {
    code += chars[Math.floor(Math.random() * chars.length)];
  }
  // Format as XXX-XXX
  return `${code.slice(0, 3)}-${code.slice(3)}`;
}

// Cleanup expired requests every minute
setInterval(() => {
  const now = Date.now();
  for (const [device_code, request] of deviceRequests.entries()) {
    if (request.expires_at < now) {
      request.status = 'expired';
    }
  }
}, 60000);

export const authRoutes: FastifyPluginAsync = async (server) => {
  // Start device authorization flow
  server.post('/auth/device/start', async (request, reply) => {
    const device_code = generateCode(32);
    const user_code = generateUserCode();
    const expires_at = Date.now() + 15 * 60 * 1000; // 15 minutes

    const authRequest: DeviceAuthRequest = {
      device_code,
      user_code,
      verification_url: 'http://localhost:3000/auth/device',
      expires_at,
      status: 'pending',
    };

    deviceRequests.set(device_code, authRequest);

    return {
      device_code,
      user_code,
      verification_url: authRequest.verification_url,
      expires_in: 900, // 15 minutes in seconds
      interval: 5, // Poll every 5 seconds
    };
  });

  // Poll device authorization status
  server.post('/auth/device/poll', async (request, reply) => {
    const { device_code } = request.body as { device_code: string };

    const authRequest = deviceRequests.get(device_code);

    if (!authRequest) {
      return reply.status(404).send({ error: 'Device code not found' });
    }

    if (authRequest.expires_at < Date.now()) {
      authRequest.status = 'expired';
    }

    if (authRequest.status === 'authorized') {
      return {
        status: 'authorized',
        access_token: authRequest.access_token,
      };
    }

    if (authRequest.status === 'denied') {
      return reply.status(403).send({
        error: 'authorization_denied',
        message: 'User denied the authorization request',
      });
    }

    if (authRequest.status === 'expired') {
      return reply.status(410).send({
        error: 'expired_token',
        message: 'Device code has expired',
      });
    }

    return {
      status: 'pending',
      message: 'User has not yet authorized this device',
    };
  });

  // Confirm device authorization (called by WebGUI)
  server.post('/auth/device/confirm', async (request, reply) => {
    const { user_code, action } = request.body as {
      user_code: string;
      action?: 'approve' | 'deny';
    };

    // Find the request with this user code
    let authRequest: DeviceAuthRequest | undefined;
    for (const request of deviceRequests.values()) {
      if (request.user_code === user_code) {
        authRequest = request;
        break;
      }
    }

    if (!authRequest) {
      return reply.status(404).send({ error: 'User code not found' });
    }

    if (authRequest.expires_at < Date.now()) {
      authRequest.status = 'expired';
      return reply.status(410).send({ error: 'Code has expired' });
    }

    if (action === 'deny') {
      authRequest.status = 'denied';
      return { status: 'denied' };
    }

    // Generate access token (simple random token for now)
    const access_token = crypto.randomBytes(32).toString('hex');

    authRequest.status = 'authorized';
    authRequest.access_token = access_token;

    return {
      status: 'authorized',
      message: 'Device authorized successfully',
    };
  });

  // Get pending authorization request (for WebGUI to display)
  server.get('/auth/device/pending/:user_code', async (request, reply) => {
    const { user_code } = request.params as { user_code: string };

    // Find the request with this user code
    let authRequest: DeviceAuthRequest | undefined;
    for (const request of deviceRequests.values()) {
      if (request.user_code === user_code) {
        authRequest = request;
        break;
      }
    }

    if (!authRequest) {
      return reply.status(404).send({ error: 'User code not found' });
    }

    if (authRequest.expires_at < Date.now()) {
      authRequest.status = 'expired';
      return reply.status(410).send({ error: 'Code has expired' });
    }

    return {
      user_code: authRequest.user_code,
      status: authRequest.status,
      expires_in: Math.floor((authRequest.expires_at - Date.now()) / 1000),
    };
  });

  // POST /auth/signup - Create a new user account
  server.post('/auth/signup', async (request, reply) => {
    const { email, password, name } = request.body as {
      email: string;
      password: string;
      name?: string;
    };

    if (!email || !password) {
      return reply.status(400).send({ error: 'Email and password required' });
    }

    // Check if user already exists
    const existing = await prisma.user.findUnique({
      where: { email },
    });

    if (existing) {
      return reply.status(409).send({ error: 'User already exists' });
    }

    // Create user
    const user = await prisma.user.create({
      data: {
        email,
        passwordHash: hashPassword(password),
        name: name || email.split('@')[0],
      },
    });

    const token = generateToken();

    return reply.status(201).send({
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
      },
      token,
    });
  });

  // POST /auth/login - Login with email/password
  server.post('/auth/login', async (request, reply) => {
    const { email, password } = request.body as {
      email: string;
      password: string;
    };

    if (!email || !password) {
      return reply.status(400).send({ error: 'Email and password required' });
    }

    // Find user
    const user = await prisma.user.findUnique({
      where: { email },
    });

    if (!user || !verifyPassword(password, user.passwordHash)) {
      return reply.status(401).send({ error: 'Invalid credentials' });
    }

    const token = generateToken();

    // Store token in database
    await prisma.user.update({
      where: { id: user.id },
      data: { token },
    });

    return {
      user: {
        id: user.id,
        email: user.email,
        name: user.name,
      },
      token,
    };
  });

  // GET /auth/me - Get current user info
  server.get('/auth/me', async (request, reply) => {
    const authHeader = request.headers.authorization;
    if (!authHeader) {
      return reply.status(401).send({ error: 'No token provided' });
    }

    const token = authHeader.replace('Bearer ', '');

    // Find user by token
    const user = await prisma.user.findFirst({
      where: { token },
    });

    if (!user) {
      return reply.status(401).send({ error: 'Not authenticated' });
    }

    return {
      id: user.id,
      email: user.email,
      name: user.name,
    };
  });

  // POST /auth/logout - Invalidate current token
  server.post('/auth/logout', async (request, reply) => {
    const authHeader = request.headers.authorization;
    if (!authHeader) {
      return { success: true };
    }

    const token = authHeader.replace('Bearer ', '');

    // Clear token from user
    await prisma.user.updateMany({
      where: { token },
      data: { token: null },
    });

    return { success: true };
  });
};

inference.test.ts

Path: starbot_api/src/routes/__tests__/inference.test.ts

Size: 4656 bytes

import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import Fastify from 'fastify';
import { prisma } from '../../db.js';
import { env } from '../../env.js';
import { inferenceRoutes } from '../inference.js';
import type { ModelDefinition } from '../../services/model-catalog.js';
import * as modelCatalog from '../../services/model-catalog.js';
import * as providerRegistry from '../../providers/index.js';
import * as retrieval from '../../services/retrieval.js';

const fakeModel: ModelDefinition = {
  id: 'test-model',
  provider: 'azure',
  deploymentName: 'test-deployment',
  displayName: 'Test Model',
  tier: 2,
  capabilities: ['text', 'streaming'],
  contextWindow: 128000,
  maxOutputTokens: 4096,
  status: 'enabled',
};

function createFakeProvider() {
  return {
    name: 'fake',
    sendChat: vi.fn(async () => ({
      content: 'unused',
      usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
    })),
    sendChatStream: vi.fn(async function* () {
      yield { text: 'Hello from test' };
      yield { usage: { promptTokens: 1, completionTokens: 3, totalTokens: 4 } };
    }),
  };
}

describe.sequential('Inference Route Model Selection', () => {
  const app = Fastify();
  const originalAuth = env.AUTH_ENFORCEMENT_ENABLED;
  const originalRate = env.RATE_LIMITING_ENABLED;

  beforeAll(async () => {
    await app.register(inferenceRoutes, { prefix: '/v1' });
    await app.ready();
  });

  afterAll(async () => {
    env.AUTH_ENFORCEMENT_ENABLED = originalAuth;
    env.RATE_LIMITING_ENABLED = originalRate;
    await app.close();
  });

  afterEach(async () => {
    env.AUTH_ENFORCEMENT_ENABLED = false;
    env.RATE_LIMITING_ENABLED = false;
    vi.restoreAllMocks();

    await prisma.project.deleteMany({
      where: { name: 'CLI Default' },
    });
  });

  it('uses explicit provider/model selector when provided', async () => {
    vi.spyOn(modelCatalog, 'getModelByProviderAndName').mockResolvedValue(fakeModel);
    vi.spyOn(modelCatalog, 'getModelById').mockResolvedValue(null);
    vi.spyOn(modelCatalog, 'listModels').mockResolvedValue([]);
    vi.spyOn(modelCatalog, 'getBestModelForTier').mockResolvedValue(null);
    vi.spyOn(retrieval, 'getRelevantContext').mockResolvedValue('');
    vi.spyOn(providerRegistry, 'getProvider').mockReturnValue(createFakeProvider() as never);

    const response = await app.inject({
      method: 'POST',
      url: '/v1/inference/chat',
      payload: {
        messages: [{ role: 'user', content: 'hello' }],
        provider: 'azure',
        model: 'test-deployment',
      },
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.provider).toBe('azure');
    expect(body.model).toBe('test-deployment');

    expect(modelCatalog.getModelByProviderAndName).toHaveBeenCalledWith('azure', 'test-deployment');
  });

  it('returns 400 for unresolved explicit provider/model selector', async () => {
    vi.spyOn(modelCatalog, 'getModelByProviderAndName').mockResolvedValue(null);
    vi.spyOn(modelCatalog, 'getModelById').mockResolvedValue(null);
    vi.spyOn(modelCatalog, 'listModels').mockResolvedValue([]);
    vi.spyOn(modelCatalog, 'getBestModelForTier').mockResolvedValue(null);
    vi.spyOn(retrieval, 'getRelevantContext').mockResolvedValue('');
    vi.spyOn(providerRegistry, 'getProvider').mockReturnValue(createFakeProvider() as never);

    const response = await app.inject({
      method: 'POST',
      url: '/v1/inference/chat',
      payload: {
        messages: [{ role: 'user', content: 'hello' }],
        provider: 'azure',
        model: 'does-not-exist',
      },
    });

    expect(response.statusCode).toBe(400);
    const body = JSON.parse(response.body);
    expect(body.error).toBe('Requested provider/model is not available');
  });

  it('falls back to tier-based selection when no explicit selector is provided', async () => {
    vi.spyOn(modelCatalog, 'getBestModelForTier').mockResolvedValue(fakeModel);
    vi.spyOn(retrieval, 'getRelevantContext').mockResolvedValue('');
    vi.spyOn(providerRegistry, 'getProvider').mockReturnValue(createFakeProvider() as never);

    const response = await app.inject({
      method: 'POST',
      url: '/v1/inference/chat',
      payload: {
        messages: [{ role: 'user', content: 'fallback test' }],
        provider: 'auto',
      },
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.provider).toBe('azure');
    expect(body.model).toBe('test-deployment');
    expect(modelCatalog.getBestModelForTier).toHaveBeenCalled();
  });
});

memory.test.ts

Path: starbot_api/src/routes/__tests__/memory.test.ts

Size: 2298 bytes

import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import Fastify from 'fastify';
import { prisma } from '../../db.js';
import { memoryRoutes } from '../memory.js';

describe.sequential('Memory Routes', () => {
  const app = Fastify();
  let projectId = '';
  let chatId = '';

  beforeAll(async () => {
    await app.register(memoryRoutes, { prefix: '/v1' });
    await app.ready();
  });

  afterAll(async () => {
    await app.close();
  });

  beforeEach(async () => {
    const project = await prisma.project.create({
      data: { name: `Memory Test ${Date.now()}` },
    });
    projectId = project.id;

    const chat = await prisma.chat.create({
      data: {
        projectId,
        title: 'Memory Test Chat',
      },
    });
    chatId = chat.id;
  });

  afterEach(async () => {
    if (projectId) {
      await prisma.project.delete({
        where: { id: projectId },
      });
    }
    projectId = '';
    chatId = '';
  });

  it('auto-creates identity memory via GET /v1/identity', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/v1/identity',
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.memory).toBeDefined();
    expect(typeof body.memory.id).toBe('string');
    expect(body.memory.content).toContain('# IDENTITY');
  });

  it('auto-creates chat memory via GET /v1/chats/:chatId/memory', async () => {
    const response = await app.inject({
      method: 'GET',
      url: `/v1/chats/${chatId}/memory`,
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.memory).toBeDefined();
    expect(typeof body.memory.id).toBe('string');
    expect(body.memory.content).toContain('# Chat Memory');
  });

  it('updates chat memory via PUT /v1/chats/:chatId/memory', async () => {
    const updatedContent = '# Chat Memory\n\n- Decision: use provider failover first.';
    const response = await app.inject({
      method: 'PUT',
      url: `/v1/chats/${chatId}/memory`,
      payload: { content: updatedContent },
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.memory.content).toBe(updatedContent);
  });
});

projects.test.ts

Path: starbot_api/src/routes/__tests__/projects.test.ts

Size: 3388 bytes

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import Fastify from 'fastify';
import { projectRoutes } from '../projects.js';

describe('Project Routes', () => {
  const app = Fastify();

  beforeAll(async () => {
    await app.register(projectRoutes, { prefix: '/v1' });
    await app.ready();
  });

  afterAll(async () => {
    await app.close();
  });

  describe('POST /v1/projects', () => {
    it('should create a new project', async () => {
      const response = await app.inject({
        method: 'POST',
        url: '/v1/projects',
        payload: {
          name: 'Test Project',
        },
      });

      expect(response.statusCode).toBe(201);
      const body = JSON.parse(response.body);
      expect(body.project).toBeDefined();
      expect(body.project.name).toBe('Test Project');
      expect(body.project.id).toBeDefined();
    });

    it('should reject empty project name', async () => {
      const response = await app.inject({
        method: 'POST',
        url: '/v1/projects',
        payload: {
          name: '',
        },
      });

      expect(response.statusCode).toBe(400);
    });
  });

  describe('GET /v1/projects', () => {
    it('should list all projects', async () => {
      const response = await app.inject({
        method: 'GET',
        url: '/v1/projects',
      });

      expect(response.statusCode).toBe(200);
      const body = JSON.parse(response.body);
      expect(body.projects).toBeDefined();
      expect(Array.isArray(body.projects)).toBe(true);
    });
  });

  describe('GET /v1/projects/:id', () => {
    it('should return 404 for non-existent project', async () => {
      const response = await app.inject({
        method: 'GET',
        url: '/v1/projects/non-existent-id',
      });

      expect(response.statusCode).toBe(404);
    });
  });

  describe('PUT /v1/projects/:id', () => {
    it('should update project name', async () => {
      // First create a project
      const createResponse = await app.inject({
        method: 'POST',
        url: '/v1/projects',
        payload: { name: 'Original Name' },
      });

      const { project } = JSON.parse(createResponse.body);

      // Then update it
      const updateResponse = await app.inject({
        method: 'PUT',
        url: `/v1/projects/${project.id}`,
        payload: { name: 'Updated Name' },
      });

      expect(updateResponse.statusCode).toBe(200);
      const body = JSON.parse(updateResponse.body);
      expect(body.project.name).toBe('Updated Name');
    });
  });

  describe('DELETE /v1/projects/:id', () => {
    it('should delete a project', async () => {
      // First create a project
      const createResponse = await app.inject({
        method: 'POST',
        url: '/v1/projects',
        payload: { name: 'To Delete' },
      });

      const { project } = JSON.parse(createResponse.body);

      // Then delete it
      const deleteResponse = await app.inject({
        method: 'DELETE',
        url: `/v1/projects/${project.id}`,
      });

      expect(deleteResponse.statusCode).toBe(200);
      const body = JSON.parse(deleteResponse.body);
      expect(body.ok).toBe(true);

      // Verify it's gone
      const getResponse = await app.inject({
        method: 'GET',
        url: `/v1/projects/${project.id}`,
      });

      expect(getResponse.statusCode).toBe(404);
    });
  });
});

protection.test.ts

Path: starbot_api/src/routes/__tests__/protection.test.ts

Size: 4138 bytes

import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import Fastify from 'fastify';
import { env } from '../../env.js';
import { generationRoutes } from '../generation.js';
import { inferenceRoutes } from '../inference.js';

describe.sequential('Protected Route Guards', () => {
  const app = Fastify();

  const originalAuth = env.AUTH_ENFORCEMENT_ENABLED;
  const originalRate = env.RATE_LIMITING_ENABLED;
  const originalWindow = env.RATE_LIMIT_WINDOW_MS;
  const originalRunLimit = env.RATE_LIMIT_RUN_PER_WINDOW;
  const originalInferenceLimit = env.RATE_LIMIT_INFERENCE_PER_WINDOW;

  beforeAll(async () => {
    await app.register(generationRoutes, { prefix: '/v1' });
    await app.register(inferenceRoutes, { prefix: '/v1' });
    await app.ready();
  });

  afterAll(async () => {
    env.AUTH_ENFORCEMENT_ENABLED = originalAuth;
    env.RATE_LIMITING_ENABLED = originalRate;
    env.RATE_LIMIT_WINDOW_MS = originalWindow;
    env.RATE_LIMIT_RUN_PER_WINDOW = originalRunLimit;
    env.RATE_LIMIT_INFERENCE_PER_WINDOW = originalInferenceLimit;
    await app.close();
  });

  afterEach(() => {
    env.AUTH_ENFORCEMENT_ENABLED = false;
    env.RATE_LIMITING_ENABLED = false;
    env.RATE_LIMIT_WINDOW_MS = 60000;
    env.RATE_LIMIT_RUN_PER_WINDOW = 8;
    env.RATE_LIMIT_INFERENCE_PER_WINDOW = 30;
  });

  it('returns 401 on /v1/chats/:chatId/run when auth enforcement is enabled and no token is provided', async () => {
    env.AUTH_ENFORCEMENT_ENABLED = true;
    env.RATE_LIMITING_ENABLED = false;

    const response = await app.inject({
      method: 'POST',
      url: '/v1/chats/does-not-matter/run',
      payload: { mode: 'standard' },
    });

    expect(response.statusCode).toBe(401);
    const body = JSON.parse(response.body);
    expect(body.error).toBe('unauthorized');
  });

  it('returns 401 on /v1/inference/chat when auth enforcement is enabled and no token is provided', async () => {
    env.AUTH_ENFORCEMENT_ENABLED = true;
    env.RATE_LIMITING_ENABLED = false;

    const response = await app.inject({
      method: 'POST',
      url: '/v1/inference/chat',
      payload: {
        messages: [{ role: 'user', content: 'hello' }],
      },
    });

    expect(response.statusCode).toBe(401);
    const body = JSON.parse(response.body);
    expect(body.error).toBe('unauthorized');
  });

  it('returns 429 on /v1/chats/:chatId/run when rate limit is exceeded', async () => {
    env.AUTH_ENFORCEMENT_ENABLED = false;
    env.RATE_LIMITING_ENABLED = true;
    env.RATE_LIMIT_WINDOW_MS = 60000;
    env.RATE_LIMIT_RUN_PER_WINDOW = 1;

    const first = await app.inject({
      method: 'POST',
      url: '/v1/chats/does-not-matter/run',
      headers: {
        'x-api-token': 'rate-test-token',
      },
      payload: { mode: 'standard' },
    });

    expect(first.statusCode).toBe(404);

    const second = await app.inject({
      method: 'POST',
      url: '/v1/chats/does-not-matter/run',
      headers: {
        'x-api-token': 'rate-test-token',
      },
      payload: { mode: 'standard' },
    });

    expect(second.statusCode).toBe(429);
    const body = JSON.parse(second.body);
    expect(body.error).toBe('rate_limited');
  });

  it('returns 429 on /v1/inference/chat when rate limit is exceeded', async () => {
    env.AUTH_ENFORCEMENT_ENABLED = false;
    env.RATE_LIMITING_ENABLED = true;
    env.RATE_LIMIT_WINDOW_MS = 60000;
    env.RATE_LIMIT_INFERENCE_PER_WINDOW = 1;

    const first = await app.inject({
      method: 'POST',
      url: '/v1/inference/chat',
      headers: {
        'x-api-token': 'rate-test-token',
      },
      payload: {
        messages: [{ role: 'user', content: 'hello' }],
      },
    });

    expect(first.statusCode).not.toBe(429);

    const second = await app.inject({
      method: 'POST',
      url: '/v1/inference/chat',
      headers: {
        'x-api-token': 'rate-test-token',
      },
      payload: {
        messages: [{ role: 'user', content: 'hello' }],
      },
    });

    expect(second.statusCode).toBe(429);
    const body = JSON.parse(second.body);
    expect(body.error).toBe('rate_limited');
  });
});

generation.test.ts

Path: starbot_api/src/routes/__tests__/generation.test.ts

Size: 9071 bytes

import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest';
import Fastify from 'fastify';
import { generationRoutes } from '../generation.js';
import { prisma } from '../../db.js';
import * as interpreter from '../../services/interpreter.js';
import * as webSearch from '../../services/web-search.js';
import * as retrieval from '../../services/retrieval.js';
import type { FastifyInstance } from 'fastify';

describe('Generation Route (POST /v1/chats/:chatId/run)', () => {
  let app: FastifyInstance;
  let projectId: string;
  let workspaceId: string;
  let chatId: string;

  beforeAll(async () => {
    app = Fastify({ logger: false });
    await app.register(generationRoutes, { prefix: '/v1' });
    await app.ready();

    // Create test project
    const project = await prisma.project.create({
      data: {
        name: `Test Project ${Date.now()}`,
      },
    });
    projectId = project.id;

    // Create test workspace
    const workspace = await prisma.workspace.create({
      data: {
        projectId,
        type: 'folder',
        identifier: '/test/workspace',
      },
    });
    workspaceId = workspace.id;

    // Create test chat
    const chat = await prisma.chat.create({
      data: {
        projectId,
        workspaceId,
        title: 'New Chat',
      },
    });
    chatId = chat.id;

    // Add initial user message
    await prisma.message.create({
      data: {
        chatId,
        role: 'user',
        content: 'Hello, how are you?',
      },
    });
  });

  afterAll(async () => {
    await app.close();

    // Cleanup
    if (chatId) {
      await prisma.message.deleteMany({ where: { chatId } });
      await prisma.chat.delete({ where: { id: chatId } }).catch(() => {});
    }
    if (workspaceId) {
      await prisma.workspace.delete({ where: { id: workspaceId } }).catch(() => {});
    }
    if (projectId) {
      await prisma.project.delete({ where: { id: projectId } }).catch(() => {});
    }
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('Chat Not Found', () => {
    it('should return 404 for nonexistent chat', async () => {
      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/nonexistent-chat-id/run`,
        payload: {},
      });

      expect(response.statusCode).toBe(404);
      expect(response.json()).toHaveProperty('error');
    });
  });

  describe('Interpreter Integration', () => {
    it('should include interpreter events in response', async () => {
      vi.spyOn(interpreter, 'interpretUserMessage').mockResolvedValue({
        shouldClarify: false,
        normalizedUserMessage: 'Test message',
        primaryIntent: 'chat',
        intents: ['chat'],
        confidence: 0.85,
        reason: 'test_reason',
      });

      vi.spyOn(retrieval, 'getRelevantContext').mockResolvedValue('');
      vi.spyOn(retrieval, 'getIdentityContext').mockResolvedValue('');
      vi.spyOn(retrieval, 'getChatMemoryContext').mockResolvedValue('');

      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/run`,
        payload: {},
      });

      expect(response.statusCode).toBe(200);
      const body = response.body;
      // Check that SSE events are in response (from interpreter pass and status)
      expect(body).toContain('event:');
      expect(body).toContain('status');
    });
  });

  describe('Memory Injection', () => {
    it('should handle memory retrieval in response', async () => {
      vi.spyOn(interpreter, 'interpretUserMessage').mockResolvedValue({
        shouldClarify: false,
        normalizedUserMessage: 'Test',
        primaryIntent: 'chat',
        intents: ['chat'],
        confidence: 0.9,
        reason: 'test',
      });

      vi.spyOn(retrieval, 'getRelevantContext').mockResolvedValue('# Memory\nSome context');
      vi.spyOn(retrieval, 'getIdentityContext').mockResolvedValue('');
      vi.spyOn(retrieval, 'getChatMemoryContext').mockResolvedValue('');

      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/run`,
        payload: {},
      });

      expect(response.statusCode).toBe(200);
      const body = response.body;
      // Should include status events showing memory processing
      expect(body).toContain('event:');
      expect(body).toContain('status');
    });
  });

  describe('Clarification Handling', () => {
    it('should handle clarification responses from interpreter', async () => {
      // Add ambiguous message
      await prisma.message.create({
        data: {
          chatId,
          role: 'user',
          content: 'Something unclear',
        },
      });

      vi.spyOn(interpreter, 'interpretUserMessage').mockResolvedValue({
        shouldClarify: true,
        clarificationQuestion: 'Could you provide more details?',
        normalizedUserMessage: 'Something unclear',
        primaryIntent: 'clarify',
        intents: ['clarify'],
        confidence: 0.5,
        reason: 'ambiguous_request',
      });

      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/run`,
        payload: {},
      });

      expect(response.statusCode).toBe(200);
      const body = response.body;
      // Should send clarification as final message
      expect(body).toContain('event: message.final');
      expect(body).toContain('Could you provide more details');
    });
  });

  describe('Error Handling', () => {
    it('should handle missing user message gracefully', async () => {
      // Create chat with no messages
      const emptyChat = await prisma.chat.create({
        data: {
          projectId,
          workspaceId,
          title: 'Empty Chat',
        },
      });

      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${emptyChat.id}/run`,
        payload: {},
      });

      expect(response.statusCode).toBe(200);
      const body = response.body;
      expect(body).toContain('event: error');

      await prisma.chat.delete({ where: { id: emptyChat.id } });
    });

    it('should send error event on interpreter failure', async () => {
      vi.spyOn(interpreter, 'interpretUserMessage').mockRejectedValue(
        new Error('Interpreter service unavailable')
      );

      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/run`,
        payload: {},
      });

      expect(response.statusCode).toBe(200);
      const body = response.body;
      expect(body).toContain('event: error');
    });
  });

  describe('Request Body Parameters', () => {
    it('should validate route parameters', async () => {
      // Basic validation that endpoint exists and validates input
      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/run`,
        payload: {
          mode: 'deep',
          speed: true,
        },
      });

      // Should be 200 (success with mocks) or 500 (if provider fails)
      // But not 404 or validation error
      expect([200, 500]).toContain(response.statusCode);
    });
  });

  describe('SSE Event Stream', () => {
    it('should return properly formatted SSE headers', async () => {
      vi.spyOn(interpreter, 'interpretUserMessage').mockResolvedValue({
        shouldClarify: false,
        normalizedUserMessage: 'Test',
        primaryIntent: 'chat',
        intents: ['chat'],
        confidence: 0.9,
        reason: 'test',
      });

      vi.spyOn(retrieval, 'getRelevantContext').mockResolvedValue('');
      vi.spyOn(retrieval, 'getIdentityContext').mockResolvedValue('');
      vi.spyOn(retrieval, 'getChatMemoryContext').mockResolvedValue('');

      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/run`,
        payload: {},
      });

      expect(response.statusCode).toBe(200);
      expect(response.headers['content-type']).toContain('text/event-stream');
      expect(response.headers['cache-control']).toBe('no-cache');
      expect(response.headers['connection']).toBe('keep-alive');
      // Verify response contains event data
      expect(response.body.length).toBeGreaterThan(0);
    });

    it('should include status events', async () => {
      vi.spyOn(interpreter, 'interpretUserMessage').mockResolvedValue({
        shouldClarify: false,
        normalizedUserMessage: 'Test',
        primaryIntent: 'chat',
        intents: ['chat'],
        confidence: 0.9,
        reason: 'test',
      });

      vi.spyOn(retrieval, 'getRelevantContext').mockResolvedValue('');
      vi.spyOn(retrieval, 'getIdentityContext').mockResolvedValue('');
      vi.spyOn(retrieval, 'getChatMemoryContext').mockResolvedValue('');

      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/run`,
        payload: {},
      });

      expect(response.statusCode).toBe(200);
      const body = response.body;
      // Should contain events from the route
      expect(body).toMatch(/event: \w+/);
    });
  });
});

messages.test.ts

Path: starbot_api/src/routes/__tests__/messages.test.ts

Size: 5517 bytes

import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import Fastify from 'fastify';
import { prisma } from '../../db.js';
import { messageRoutes } from '../messages.js';

describe.sequential('Message Routes', () => {
  const app = Fastify();
  let projectId = '';
  let chatId = '';

  beforeAll(async () => {
    await app.register(messageRoutes, { prefix: '/v1' });
    await app.ready();
  });

  afterAll(async () => {
    await app.close();
  });

  beforeEach(async () => {
    const project = await prisma.project.create({
      data: { name: `Messages Test ${Date.now()}` },
    });
    projectId = project.id;

    const chat = await prisma.chat.create({
      data: {
        projectId,
        title: 'Messages Test Chat',
      },
    });
    chatId = chat.id;
  });

  afterEach(async () => {
    if (projectId) {
      await prisma.project.delete({
        where: { id: projectId },
      });
    }
    projectId = '';
    chatId = '';
  });

  it('updates a message via PUT /v1/messages/:id', async () => {
    const message = await prisma.message.create({
      data: {
        chatId,
        role: 'user',
        content: 'Original content',
      },
    });

    const response = await app.inject({
      method: 'PUT',
      url: `/v1/messages/${message.id}`,
      payload: { content: 'Updated content' },
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.message.id).toBe(message.id);
    expect(body.message.content).toBe('Updated content');
  });

  it('deletes a message via DELETE /v1/messages/:id', async () => {
    const message = await prisma.message.create({
      data: {
        chatId,
        role: 'user',
        content: 'Delete me',
      },
    });

    const response = await app.inject({
      method: 'DELETE',
      url: `/v1/messages/${message.id}`,
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.ok).toBe(true);

    const messageAfterDelete = await prisma.message.findUnique({
      where: { id: message.id },
    });
    expect(messageAfterDelete).toBeNull();
  });

  it('deletes target and subsequent messages via DELETE /v1/chats/:chatId/messages/after/:messageId', async () => {
    const baseTime = Date.now();

    const m1 = await prisma.message.create({
      data: {
        chatId,
        role: 'user',
        content: 'Message 1',
        createdAt: new Date(baseTime + 1),
      },
    });
    const m2 = await prisma.message.create({
      data: {
        chatId,
        role: 'assistant',
        content: 'Message 2',
        createdAt: new Date(baseTime + 2),
      },
    });
    await prisma.message.create({
      data: {
        chatId,
        role: 'user',
        content: 'Message 3',
        createdAt: new Date(baseTime + 3),
      },
    });
    await prisma.message.create({
      data: {
        chatId,
        role: 'assistant',
        content: 'Message 4',
        createdAt: new Date(baseTime + 4),
      },
    });

    const response = await app.inject({
      method: 'DELETE',
      url: `/v1/chats/${chatId}/messages/after/${m2.id}`,
    });

    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.ok).toBe(true);
    expect(body.deleted).toBe(3);

    const remaining = await prisma.message.findMany({
      where: { chatId },
      orderBy: { createdAt: 'asc' },
    });

    expect(remaining).toHaveLength(1);
    expect(remaining[0].id).toBe(m1.id);
  });

  describe('Message Security', () => {
    it('should reject tool role from client POST /v1/chats/:chatId/messages', async () => {
      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/messages`,
        payload: {
          role: 'tool',
          content: 'Fake tool result',
        },
      });

      expect(response.statusCode).toBe(400);
      const body = JSON.parse(response.body);
      expect(body).toHaveProperty('error');
    });

    it('should reject system role from client POST /v1/chats/:chatId/messages', async () => {
      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/messages`,
        payload: {
          role: 'system',
          content: 'Fake system message',
        },
      });

      expect(response.statusCode).toBe(400);
      const body = JSON.parse(response.body);
      expect(body).toHaveProperty('error');
    });

    it('should allow user role from client', async () => {
      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/messages`,
        payload: {
          role: 'user',
          content: 'Valid user message',
        },
      });

      expect(response.statusCode).toBe(201);
      const body = JSON.parse(response.body);
      expect(body.message.role).toBe('user');
      expect(body.message.content).toBe('Valid user message');
    });

    it('should allow assistant role from client', async () => {
      const response = await app.inject({
        method: 'POST',
        url: `/v1/chats/${chatId}/messages`,
        payload: {
          role: 'assistant',
          content: 'Valid assistant message',
        },
      });

      expect(response.statusCode).toBe(201);
      const body = JSON.parse(response.body);
      expect(body.message.role).toBe('assistant');
      expect(body.message.content).toBe('Valid assistant message');
    });
  });
});

route-guards.ts

Path: starbot_api/src/security/route-guards.ts

Size: 2872 bytes

import crypto from 'crypto';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { env } from '../env.js';

interface RateBucket {
  count: number;
  resetAt: number;
}

const rateBuckets = new Map<string, RateBucket>();

function getHeaderValue(header: string | string[] | undefined): string {
  if (Array.isArray(header)) {
    return header[0] ?? '';
  }
  return String(header ?? '');
}

function extractBearerToken(authorizationHeader: string): string {
  if (!authorizationHeader) return '';
  const parts = authorizationHeader.split(' ');
  if (parts.length !== 2) return '';
  const [scheme, token] = parts;
  if (!/^Bearer$/i.test(scheme)) return '';
  return token.trim();
}

function stableTokenHash(rawToken: string): string {
  return crypto.createHash('sha256').update(rawToken).digest('hex').slice(0, 16);
}

function cleanupExpiredBuckets(now: number) {
  if (rateBuckets.size < 5000) return;
  for (const [key, value] of rateBuckets.entries()) {
    if (value.resetAt <= now) {
      rateBuckets.delete(key);
    }
  }
}

export function extractAuthToken(request: FastifyRequest): string {
  const xApiToken = getHeaderValue(request.headers['x-api-token']).trim();
  if (xApiToken) return xApiToken;

  const authHeader = getHeaderValue(request.headers.authorization);
  return extractBearerToken(authHeader);
}

export function requireAuthIfEnabled(request: FastifyRequest, reply: FastifyReply): boolean {
  if (!env.AUTH_ENFORCEMENT_ENABLED) return true;

  const token = extractAuthToken(request);
  if (token) return true;

  reply.code(401).send({
    error: 'unauthorized',
    message: 'Missing API token',
  });
  return false;
}

interface RateLimitOptions {
  routeKey: string;
  maxRequests: number;
}

export function enforceRateLimitIfEnabled(
  request: FastifyRequest,
  reply: FastifyReply,
  options: RateLimitOptions,
): boolean {
  if (!env.RATE_LIMITING_ENABLED) return true;

  const now = Date.now();
  cleanupExpiredBuckets(now);

  const token = extractAuthToken(request);
  const clientKey = token
    ? `token:${stableTokenHash(token)}`
    : `ip:${request.ip || 'unknown'}`;
  const bucketKey = `${options.routeKey}:${clientKey}`;

  const current = rateBuckets.get(bucketKey);
  if (!current || current.resetAt <= now) {
    rateBuckets.set(bucketKey, {
      count: 1,
      resetAt: now + env.RATE_LIMIT_WINDOW_MS,
    });
    return true;
  }

  if (current.count >= options.maxRequests) {
    const retryAfterSeconds = Math.max(1, Math.ceil((current.resetAt - now) / 1000));
    reply.header('Retry-After', String(retryAfterSeconds));
    reply.code(429).send({
      error: 'rate_limited',
      message: 'Too many requests. Please try again later.',
      retry_after_seconds: retryAfterSeconds,
    });
    return false;
  }

  current.count += 1;
  rateBuckets.set(bucketKey, current);
  return true;
}

web-search.ts

Path: starbot_api/src/services/web-search.ts

Size: 2030 bytes

import { env } from '../env.js';

export interface WebSearchHit {
  title: string;
  url: string;
  snippet: string;
}

export interface WebSearchResult {
  query: string;
  hits: WebSearchHit[];
}

function normalizeText(value: unknown): string {
  return String(value ?? '').trim();
}

export async function searchWeb(query: string, count = 5): Promise<WebSearchResult | null> {
  if (!env.WEB_SEARCH_ENABLED) return null;
  if (!env.BRAVE_SEARCH_API_KEY) return null;

  const q = normalizeText(query);
  if (!q) return null;

  const endpoint = new URL('https://api.search.brave.com/res/v1/web/search');
  endpoint.searchParams.set('q', q);
  endpoint.searchParams.set('count', String(Math.max(1, Math.min(count, 10))));

  const response = await fetch(endpoint.toString(), {
    method: 'GET',
    headers: {
      Accept: 'application/json',
      'X-Subscription-Token': env.BRAVE_SEARCH_API_KEY,
    },
  });

  if (!response.ok) {
    throw new Error(`Brave Search error (${response.status})`);
  }

  const payload = (await response.json()) as {
    web?: {
      results?: Array<{
        title?: string;
        url?: string;
        description?: string;
      }>;
    };
  };

  const hits = (payload.web?.results || [])
    .map((item) => ({
      title: normalizeText(item.title),
      url: normalizeText(item.url),
      snippet: normalizeText(item.description),
    }))
    .filter((item) => item.title && item.url)
    .slice(0, Math.max(1, Math.min(count, 10)));

  return {
    query: q,
    hits,
  };
}

export function formatWebSearchContext(result: WebSearchResult): string {
  const lines: string[] = [];
  lines.push(`Web search results for query: "${result.query}"`);
  lines.push('Use these results as external context. Cite URLs when relevant.');
  lines.push('');

  result.hits.forEach((hit, idx) => {
    lines.push(`${idx + 1}. ${hit.title}`);
    lines.push(`   URL: ${hit.url}`);
    if (hit.snippet) {
      lines.push(`   Snippet: ${hit.snippet}`);
    }
  });

  return lines.join('\n');
}

interpreter.ts

Path: starbot_api/src/services/interpreter.ts

Size: 12639 bytes

import { env } from '../env.js';
import { getProvider } from '../providers/index.js';

export type InterpreterIntent = 'chat' | 'browse' | 'filesystem' | 'code' | 'shell' | 'tool' | 'clarify';

export interface InterpretationResult {
  shouldClarify: boolean;
  clarificationQuestion?: string;
  normalizedUserMessage: string;
  primaryIntent: InterpreterIntent;
  intents: InterpreterIntent[];
  confidence: number;
  reason?: string;
}

function clampConfidence(value: unknown, fallback = 0.5): number {
  const num = typeof value === 'number' ? value : Number(value);
  if (!Number.isFinite(num)) return fallback;
  return Math.max(0, Math.min(1, num));
}

function extractJsonObject(raw: string): string | null {
  const start = raw.indexOf('{');
  const end = raw.lastIndexOf('}');
  if (start === -1 || end === -1 || end <= start) return null;
  return raw.slice(start, end + 1);
}

function normalizeIntent(raw: unknown): InterpreterIntent | null {
  const value = String(raw || '').trim().toLowerCase();
  if (!value) return null;
  if (value === 'clarify') return 'clarify';
  if (['browse', 'web', 'web_search', 'research', 'search'].includes(value)) return 'browse';
  if (
    ['filesystem', 'files', 'file', 'directory', 'workspace', 'repo', 'folder', 'local', 'glob', 'grep'].includes(
      value,
    )
  ) {
    return 'filesystem';
  }
  if (['shell', 'terminal', 'command', 'exec', 'bash', 'sh'].includes(value)) return 'shell';
  if (['code', 'coding', 'programming', 'edit', 'refactor', 'debug'].includes(value)) {
    return 'code';
  }
  if (['tool', 'tools', 'function', 'function_call', 'action'].includes(value)) return 'tool';
  if (['chat', 'general', 'qa', 'question'].includes(value)) return 'chat';
  return null;
}

function dedupeIntents(intents: InterpreterIntent[]): InterpreterIntent[] {
  const out: InterpreterIntent[] = [];
  for (const intent of intents) {
    if (!out.includes(intent)) out.push(intent);
  }
  return out;
}

function heuristicIntents(message: string): InterpreterIntent[] {
  const text = message.toLowerCase();
  const intents: InterpreterIntent[] = [];

  // Filesystem intent detection (including write/edit/save operations)
  if (
    /\b(ls|pwd|cat|mkdir|rm|cp|mv)\b/.test(text) ||
    text.includes('directory') ||
    text.includes('folder') ||
    text.includes('files in') ||
    text.includes('file list') ||
    text.includes('workspace') ||
    text.includes('open file') ||
    text.includes('read file') ||
    text.includes('save as') ||
    text.includes('write to') ||
    text.includes('create file') ||
    text.includes('delete file') ||
    // Write/save/edit operations
    text.includes('save file') ||
    text.includes('save this') ||
    text.includes('write file') ||
    text.includes('create new file') ||
    text.includes('edit file') ||
    text.includes('modify file') ||
    text.includes('update file') ||
    text.includes('change file') ||
    text.includes('replace in file') ||
    text.includes('patch file') ||
    text.includes('make file') ||
    text.includes('generate file') ||
    text.includes('create a') && (text.includes('file') || text.includes('script')) ||
    text.includes('write code') ||
    /\b(show|list|display)\s+(files?|contents?)\b/i.test(text)
  ) {
    intents.push('filesystem');
  }

  // Browse/web search intent detection
  if (
    text.includes('browse') ||
    text.includes('search the web') ||
    text.includes('search online') ||
    text.includes('look up online') ||
    text.includes('find online') ||
    text.includes('google') ||
    text.includes('look up') ||
    text.includes('latest') ||
    text.includes('news') ||
    text.includes('current') ||
    text.includes('today') ||
    /what(?:'s| is) the (?:weather|time|date|price)/i.test(text)
  ) {
    intents.push('browse');
  }

  // Shell/terminal intent detection
  if (
    text.includes('run ') ||
    text.includes('execute ') ||
    text.includes('build') ||
    text.includes('test') ||
    text.includes('install') ||
    text.includes('npm ') ||
    text.includes('yarn ') ||
    text.includes('pnpm ') ||
    text.includes('git ') ||
    text.includes('docker ') ||
    text.includes('kubectl ') ||
    text.includes('curl ') ||
    text.includes('compile') ||
    text.includes('deploy') ||
    text.startsWith('cd ') ||
    text.startsWith('ls ') ||
    text.startsWith('rm ') ||
    text.startsWith('mv ') ||
    text.startsWith('cp ')
  ) {
    intents.push('shell');
  }

  // Code intent detection
  if (
    text.includes('code') ||
    text.includes('debug') ||
    text.includes('refactor') ||
    text.includes('fix this')
  ) {
    intents.push('code');
  }

  if (intents.length === 0) intents.push('chat');
  return dedupeIntents(intents);
}

export async function interpretUserMessage(message: string): Promise<InterpretationResult> {
  const original = String(message || '').trim();
  if (!original) {
    return {
      shouldClarify: true,
      clarificationQuestion: 'What would you like me to do?',
      normalizedUserMessage: '',
      primaryIntent: 'clarify',
      intents: ['clarify'],
      confidence: 1,
      reason: 'empty_message',
    };
  }

  if (!env.INTERPRETER_ENABLED) {
    const intents = heuristicIntents(original);
    return {
      shouldClarify: false,
      normalizedUserMessage: original,
      primaryIntent: intents[0] || 'chat',
      intents,
      confidence: 1,
      reason: 'interpreter_disabled',
    };
  }

  try {
    const provider = getProvider('cloudflare');
    const response = await provider.sendChat(
      [
        {
          role: 'system',
          content:
            'You are a request interpreter and router for a Codex-like AI coding assistant. Return JSON only.\n\n' +
            'Schema: {"action":"execute|clarify","primary_intent":"chat|browse|filesystem|code|shell|tool|clarify","intents":["chat|browse|filesystem|code|shell|tool|clarify"],"normalized_user_message":"string","clarification_question":"string","confidence":0..1,"reason":"string"}\n\n' +
            'INSTRUCTIONS:\n' +
            '- Use primary_intent=shell for terminal commands, git operations, npm/yarn/pnpm, build commands, tests\n' +
            '- Use primary_intent=browse for web lookup/research/current info requests\n' +
            '- Use primary_intent=filesystem for file operations: glob (find files), grep (search in files), read, write, edit files\n' +
            '- Use primary_intent=code for debugging/refactoring/programming tasks that require code generation\n' +
            '- Use primary_intent=chat for general knowledge/explanation questions (default)\n' +
            '- Use action=clarify only when critical details are missing\n\n' +
            'KEYWORD MAPPINGS:\n' +
            '- "find all", "where is", "search for", "look for" -> filesystem (use grep/glob)\n' +
            '- "run", "execute", "build", "test", "install", "git" -> shell\n' +
            '- "create", "write", "save", "edit", "modify" -> filesystem (write/edit tools)\n' +
            '- "list files", "ls", "directory", "folder" -> filesystem (glob/ls)\n' +
            '- "web", "online", "latest", "news" -> browse\n\n' +
            'EXAMPLES:\n\n' +
            '{"action":"execute","primary_intent":"shell","intents":["shell"],"normalized_user_message":"Run npm run build","confidence":0.98,"reason":"build_command"}\n\n' +
            '{"action":"execute","primary_intent":"filesystem","intents":["filesystem"],"normalized_user_message":"Find all TypeScript files in src/","confidence":0.95,"reason":"glob_pattern"}\n\n' +
            '{"action":"execute","primary_intent":"filesystem","intents":["filesystem"],"normalized_user_message":"Search for function authenticate in the codebase","confidence":0.95,"reason":"grep_search"}\n\n' +
            '{"action":"execute","primary_intent":"filesystem","intents":["filesystem"],"normalized_user_message":"Create a new file hello.py with print hello world","confidence":0.98,"reason":"file_creation"}\n\n' +
            '{"action":"execute","primary_intent":"browse","intents":["browse"],"normalized_user_message":"What\'s the weather in Paris?","confidence":0.95,"reason":"current_info_needed"}\n\n' +
            '{"action":"execute","primary_intent":"filesystem","intents":["filesystem"],"normalized_user_message":"List files in src/","confidence":0.98,"reason":"filesystem_command"}\n\n' +
            '{"action":"execute","primary_intent":"code","intents":["code"],"normalized_user_message":"Fix the bug in main.ts","confidence":0.85,"reason":"code_modification"}\n\n' +
            '{"action":"execute","primary_intent":"chat","intents":["chat"],"normalized_user_message":"Explain recursion","confidence":0.9,"reason":"knowledge_query"}\n\n' +
            '{"action":"clarify","primary_intent":"clarify","intents":["clarify"],"clarification_question":"What would you like me to do? Please provide more context.","confidence":0.4,"reason":"ambiguous_request"}\n\n' +
            'Now classify this request:',
        },
        {
          role: 'user',
          content: `${original}`,
        },
      ],
      {
        model: env.INTERPRETER_MODEL,
        maxTokens: env.INTERPRETER_MAX_TOKENS,
        temperature: 0.1,
      },
    );

    const jsonText = extractJsonObject(response.content);
    if (!jsonText) {
      const intents = heuristicIntents(original);
      return {
        shouldClarify: false,
        normalizedUserMessage: original,
        primaryIntent: intents[0] || 'chat',
        intents,
        confidence: 0.35,
        reason: 'interpreter_non_json',
      };
    }

    const parsed = JSON.parse(jsonText) as {
      action?: string;
      primary_intent?: string;
      intent?: string;
      intents?: string[];
      normalized_user_message?: string;
      clarification_question?: string;
      confidence?: number;
      reason?: string;
    };

    const action = String(parsed.action || 'execute').trim().toLowerCase();
    const normalized = String(parsed.normalized_user_message || '').trim() || original;
    const clarification = String(parsed.clarification_question || '').trim();
    const intents = dedupeIntents(
      [
        normalizeIntent(parsed.primary_intent),
        normalizeIntent(parsed.intent),
        ...((Array.isArray(parsed.intents) ? parsed.intents : []).map((v) => normalizeIntent(v))),
      ].filter((v): v is InterpreterIntent => !!v),
    );
    const fallbackIntents = intents.length > 0 ? intents : heuristicIntents(normalized);
    const primaryIntent =
      normalizeIntent(parsed.primary_intent) || fallbackIntents[0] || (action === 'clarify' ? 'clarify' : 'chat');

    const lowerOriginal = original.toLowerCase();
    const explicitFilesystemSignal =
      /\b(ls|pwd)\b/.test(lowerOriginal) ||
      lowerOriginal.includes('directory') ||
      lowerOriginal.includes('folder') ||
      lowerOriginal.includes('files') ||
      lowerOriginal.includes('contents') ||
      lowerOriginal.includes('working directory') ||
      lowerOriginal.includes('current directory');
    const heuristicSuggestsFilesystem = fallbackIntents.includes('filesystem');
    const weakClarifyForFilesystem =
      action === 'clarify' && explicitFilesystemSignal && heuristicSuggestsFilesystem;

    if (weakClarifyForFilesystem) {
      return {
        shouldClarify: false,
        normalizedUserMessage: normalized,
        primaryIntent: 'filesystem',
        intents: dedupeIntents(['filesystem', ...fallbackIntents.filter((v) => v !== 'clarify')]),
        confidence: clampConfidence(parsed.confidence, 0.65),
        reason: 'clarify_overridden_filesystem_heuristic',
      };
    }

    if (action === 'clarify' && clarification) {
      return {
        shouldClarify: true,
        clarificationQuestion: clarification,
        normalizedUserMessage: normalized,
        primaryIntent: 'clarify',
        intents: dedupeIntents(['clarify', ...fallbackIntents]),
        confidence: clampConfidence(parsed.confidence, 0.7),
        reason: parsed.reason,
      };
    }

    return {
      shouldClarify: false,
      normalizedUserMessage: normalized,
      primaryIntent,
      intents: fallbackIntents,
      confidence: clampConfidence(parsed.confidence, 0.8),
      reason: parsed.reason,
    };
  } catch (error) {
    const intents = heuristicIntents(original);
    return {
      shouldClarify: false,
      normalizedUserMessage: original,
      primaryIntent: intents[0] || 'chat',
      intents,
      confidence: 0.25,
      reason: `interpreter_error:${error instanceof Error ? error.message : String(error)}`,
    };
  }
}

embeddings.ts

Path: starbot_api/src/services/embeddings.ts

Size: 2431 bytes

/**
 * Embedding Service
 * Generates embeddings using OpenAI text-embedding-3-large
 */

import OpenAI from 'openai';

let openaiClient: OpenAI | null = null;

/**
 * Initialize OpenAI client
 * Only creates client if OPENAI_API_KEY is available
 */
function getOpenAIClient(): OpenAI | null {
  if (openaiClient) {
    return openaiClient;
  }

  const apiKey = process.env.OPENAI_API_KEY;
  if (!apiKey) {
    console.warn('OPENAI_API_KEY not set - embeddings will not be generated');
    return null;
  }

  openaiClient = new OpenAI({ apiKey });
  return openaiClient;
}

/**
 * Generate embedding for a single text chunk
 * Uses text-embedding-3-large (3072 dimensions)
 */
export async function generateEmbedding(text: string): Promise<number[] | null> {
  const client = getOpenAIClient();
  if (!client) {
    return null;
  }

  try {
    const response = await client.embeddings.create({
      model: 'text-embedding-3-large',
      input: text,
      encoding_format: 'float',
    });

    return response.data[0].embedding;
  } catch (error) {
    console.error('Error generating embedding:', error);
    return null;
  }
}

/**
 * Generate embeddings for multiple texts in batch
 * OpenAI supports up to 2048 inputs per request
 */
export async function generateEmbeddingsBatch(
  texts: string[],
  batchSize = 100
): Promise<(number[] | null)[]> {
  const client = getOpenAIClient();
  if (!client) {
    return texts.map(() => null);
  }

  const embeddings: (number[] | null)[] = [];

  // Process in batches to avoid rate limits
  for (let i = 0; i < texts.length; i += batchSize) {
    const batch = texts.slice(i, i + batchSize);

    try {
      const response = await client.embeddings.create({
        model: 'text-embedding-3-large',
        input: batch,
        encoding_format: 'float',
      });

      embeddings.push(...response.data.map((d) => d.embedding));
    } catch (error) {
      console.error(`Error generating embeddings for batch ${i}-${i + batchSize}:`, error);
      // Add nulls for failed batch
      embeddings.push(...batch.map(() => null));
    }

    // Small delay to avoid rate limits
    if (i + batchSize < texts.length) {
      await new Promise((resolve) => setTimeout(resolve, 100));
    }
  }

  return embeddings;
}

/**
 * Check if embeddings are available (API key is set)
 */
export function areEmbeddingsAvailable(): boolean {
  return !!process.env.OPENAI_API_KEY;
}

filesystem-router.ts

Path: starbot_api/src/services/filesystem-router.ts

Size: 8195 bytes

import * as fs from 'fs/promises';
import type { Dirent } from 'fs';
import * as path from 'path';

interface Entry {
  name: string;
  size: number | null;
}

const MAX_PER_GROUP = 80;

function formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  const kb = bytes / 1024;
  if (kb < 1024) return `${kb.toFixed(1)} KB`;
  const mb = kb / 1024;
  if (mb < 1024) return `${mb.toFixed(1)} MB`;
  const gb = mb / 1024;
  return `${gb.toFixed(1)} GB`;
}

function cleanPrompt(input: string): string {
  return String(input || '')
    .trim()
    .replace(/[?!.]+$/g, '')
    .trim();
}

function normalizePathToken(token: string): string {
  return token
    .trim()
    .replace(/^the\s+/i, '')
    .replace(/\s+(folder|directory|dir|files|file|contents?|content)$/i, '')
    .trim()
    .replace(/^['"`]+|['"`]+$/g, '')
    .trim();
}

function parseListTarget(prompt: string): string | null {
  const cleaned = cleanPrompt(prompt);

  if (cleaned === 'ls') return null;
  if (cleaned.startsWith('/ls')) {
    const target = cleaned.slice(3).trim();
    return target || null;
  }
  if (cleaned.startsWith('ls ')) {
    const target = cleaned.slice(3).trim();
    return target || null;
  }

  const natural = cleaned.match(
    /^(?:what(?:'s| is)? in|whats in|show me|list|look in|look inside)\s+(?:the\s+)?(.+)$/i,
  );
  if (!natural || !natural[1]) return null;

  const candidate = normalizePathToken(natural[1]);
  if (!candidate) return null;
  if (['here', 'in here', 'there', 'this directory', 'this dir'].includes(candidate.toLowerCase())) {
    return null;
  }
  return candidate;
}

function isPwdRequest(prompt: string): boolean {
  const lower = cleanPrompt(prompt).toLowerCase();
  if (!lower) return false;
  if (lower === 'pwd' || lower === '/pwd') return true;

  return [
    'what directory are we in',
    'what dir are we in',
    'what folder are we in',
    'where are we',
    'where am i',
    'current directory',
    'current dir',
    'working directory',
    'show current directory',
  ].some((needle) => lower.includes(needle));
}

function isListRequest(prompt: string): boolean {
  const lower = cleanPrompt(prompt).toLowerCase();
  if (!lower) return false;

  if (lower === 'ls' || lower.startsWith('ls ') || lower.startsWith('/ls')) return true;

  const staticNeedles = [
    'list directory',
    'directory contents',
    'show directory',
    'show dir',
    'list dir',
    'this dir',
    'in this dir',
    "what's in this dir",
    'whats in this dir',
    'what is in this dir',
    'look at files',
    'look in there',
    'look in here',
    'show current directory',
    'list current directory',
    'files in here',
    'files here',
    'what files are here',
    'list files in this directory',
    'show files in this directory',
    'show contents',
    'show the contents',
    'directory content',
    'what is in this directory',
    "what's in this directory",
    'whats in this directory',
    'what are the contents',
    "what's the contents",
    'what is the contents',
    'tell me what the contents are',
    'contents of our working directory',
    'contents of working directory',
    'contents of current directory',
    'current folder contents',
    // Additional patterns to catch "list files" variations
    'list files',
    'show files',
    'show the files',
    'what files',
    'see the files',
    'what files are',
    'list the files',
  ];
  if (staticNeedles.some((needle) => lower.includes(needle))) return true;

  if (
    (lower.includes("what's in") ||
      lower.includes('whats in') ||
      lower.includes('what is in') ||
      lower.includes('look in') ||
      lower.includes('look inside') ||
      lower.includes('show me') ||
      lower.includes('list')) &&
    (lower.includes('folder') ||
      lower.includes('directory') ||
      lower.includes(' dir') ||
      lower.endsWith('dir'))
  ) {
    return true;
  }

  if (
    lower.includes('contents') &&
    (lower.includes('working directory') ||
      lower.includes('current directory') ||
      lower.includes('our directory') ||
      lower.includes('folder') ||
      lower.includes('directory') ||
      lower.includes(' dir') ||
      lower.includes('here') ||
      lower.includes('there') ||
      lower.includes('current') ||
      lower.includes('our'))
  ) {
    return true;
  }

  if (
    lower.startsWith('of ') &&
    (lower.includes('working directory') ||
      lower.includes('current directory') ||
      lower.includes('our directory') ||
      lower.includes('this directory') ||
      lower.includes('this dir'))
  ) {
    return true;
  }

  return false;
}

function isAccessRequest(prompt: string): boolean {
  const lower = cleanPrompt(prompt).toLowerCase();
  if (!lower) return false;
  return (
    (lower.includes('access') ||
      lower.includes('see') ||
      lower.includes('read') ||
      lower.includes('view')) &&
    (lower.includes('file') ||
      lower.includes('files') ||
      lower.includes('directory') ||
      lower.includes('folder') ||
      lower.includes('filesystem'))
  );
}

function resolveBaseDir(workingDir?: string): string {
  const candidate = String(workingDir || '').trim();
  if (!candidate) return process.cwd();
  return path.resolve(candidate);
}

async function listDirectory(baseDir: string, target: string | null): Promise<string> {
  const targetPath = target
    ? path.isAbsolute(target)
      ? path.resolve(target)
      : path.resolve(baseDir, target)
    : baseDir;

  let entries: Dirent[];
  try {
    entries = await fs.readdir(targetPath, { withFileTypes: true, encoding: 'utf8' });
  } catch (error) {
    return `Local directory listing failed for \`${targetPath}\`: ${error instanceof Error ? error.message : String(error)}`;
  }

  const dirs: Entry[] = [];
  const files: Entry[] = [];
  const other: Entry[] = [];

  for (const entry of entries) {
    const full = path.join(targetPath, entry.name);
    let size: number | null = null;
    try {
      size = (await fs.stat(full)).size;
    } catch {
      size = null;
    }

    if (entry.isDirectory()) {
      dirs.push({ name: entry.name, size });
    } else if (entry.isFile()) {
      files.push({ name: entry.name, size });
    } else {
      other.push({ name: entry.name, size });
    }
  }

  const byName = (a: Entry, b: Entry) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
  dirs.sort(byName);
  files.sort(byName);
  other.sort(byName);

  const truncated =
    dirs.length > MAX_PER_GROUP || files.length > MAX_PER_GROUP || other.length > MAX_PER_GROUP;
  const total = dirs.length + files.length + other.length;

  const lines: string[] = [];
  lines.push(
    `Local directory listing for \`${targetPath}\` (${total} entries${truncated ? ', truncated' : ''}):`,
  );

  const pushGroup = (label: string, group: Entry[], isDir: boolean) => {
    if (group.length === 0) return;
    lines.push('');
    lines.push(`${label}:`);
    for (const item of group.slice(0, MAX_PER_GROUP)) {
      const suffix = isDir ? '/' : '';
      if (item.size === null) {
        lines.push(`- ${item.name}${suffix}`);
      } else {
        lines.push(`- ${item.name}${suffix} (${formatFileSize(item.size)})`);
      }
    }
    if (group.length > MAX_PER_GROUP) {
      lines.push('- ...');
    }
  };

  pushGroup('Folders', dirs, true);
  pushGroup('Files', files, false);
  pushGroup('Other', other, false);

  return lines.join('\n');
}

export async function executeFilesystemRouterPrompt(
  message: string,
  workingDir?: string,
): Promise<string> {
  const baseDir = resolveBaseDir(workingDir);

  if (isPwdRequest(message)) {
    return `Current working directory:\n\`${baseDir}\``;
  }

  if (isAccessRequest(message)) {
    const listing = await listDirectory(baseDir, null);
    return `Yes. I have local access to files in:\n\`${baseDir}\`\n\n${listing}`;
  }

  if (isListRequest(message)) {
    return listDirectory(baseDir, parseListTarget(message));
  }

  return [
    'Filesystem router is active, but I need a concrete file task.',
    'Try: `pwd`, `ls`, `ls deploy`, or "what is in the deploy folder?"',
  ].join('\n');
}

chunking.ts

Path: starbot_api/src/services/chunking.ts

Size: 4580 bytes

/**
 * Chunking Service
 * Splits markdown content into semantic chunks for embedding
 */

export interface Chunk {
  text: string;
  heading?: string;
  tokens: number;
}

/**
 * Simple token estimator (approximation: 1 token ≈ 4 characters)
 * For production, use tiktoken library
 */
function estimateTokens(text: string): number {
  return Math.ceil(text.length / 4);
}

function splitOversizedText(text: string, maxTokens: number): string[] {
  const maxChars = Math.max(1, maxTokens * 4);
  if (text.length <= maxChars) {
    return [text];
  }

  const parts: string[] = [];
  let remaining = text;

  while (remaining.length > maxChars) {
    let splitAt = remaining.lastIndexOf(' ', maxChars);
    if (splitAt <= 0) {
      splitAt = maxChars;
    }
    parts.push(remaining.slice(0, splitAt).trim());
    remaining = remaining.slice(splitAt).trim();
  }

  if (remaining.length > 0) {
    parts.push(remaining);
  }

  return parts.filter(Boolean);
}

/**
 * Splits markdown content by headings into semantic chunks
 * Max 800 tokens per chunk to fit in embedding context
 */
export function chunkMarkdown(content: string, maxTokens = 800): Chunk[] {
  const chunks: Chunk[] = [];
  const lines = content.split('\n');

  let currentChunk: string[] = [];
  let currentHeading: string | undefined;
  let currentTokens = 0;

  const flushChunk = () => {
    if (currentChunk.length > 0) {
      const text = currentChunk.join('\n').trim();
      if (text.length > 0) {
        chunks.push({
          text,
          heading: currentHeading,
          tokens: estimateTokens(text),
        });
      }
      currentChunk = [];
      currentTokens = 0;
    }
  };

  for (const line of lines) {
    const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);

    if (headingMatch) {
      // Found a heading - flush current chunk if it exists
      flushChunk();
      currentHeading = headingMatch[2];
      currentChunk.push(line);
      currentTokens = estimateTokens(line);
    } else {
      // Regular line
      const lineTokens = estimateTokens(line);

      // A single oversized line must be split to respect max token bounds.
      if (lineTokens > maxTokens) {
        flushChunk();
        const parts = splitOversizedText(line, maxTokens);
        for (const part of parts) {
          chunks.push({
            text: part,
            heading: currentHeading,
            tokens: estimateTokens(part),
          });
        }
        continue;
      }

      // Check if adding this line would exceed max tokens
      if (currentTokens + lineTokens > maxTokens && currentChunk.length > 0) {
        flushChunk();
      }

      currentChunk.push(line);
      currentTokens += lineTokens;
    }
  }

  // Flush remaining chunk
  flushChunk();

  return chunks;
}

/**
 * Splits very large chunks that exceed max tokens
 * Used as a fallback when heading-based splitting isn't enough
 */
export function splitLargeChunk(text: string, maxTokens = 800): string[] {
  const tokens = estimateTokens(text);

  if (tokens <= maxTokens) {
    return [text];
  }

  const chunks: string[] = [];
  const sentences = text.split(/(?<=[.!?])\s+/);

  let currentChunk = '';
  let currentTokens = 0;

  for (const sentence of sentences) {
    const sentenceTokens = estimateTokens(sentence);

    if (currentTokens + sentenceTokens > maxTokens && currentChunk.length > 0) {
      chunks.push(currentChunk.trim());
      currentChunk = sentence;
      currentTokens = sentenceTokens;
    } else {
      currentChunk += (currentChunk ? ' ' : '') + sentence;
      currentTokens += sentenceTokens;
    }
  }

  if (currentChunk) {
    chunks.push(currentChunk.trim());
  }

  if (chunks.length === 0 || chunks.some(chunk => estimateTokens(chunk) > maxTokens)) {
    return splitOversizedText(text, maxTokens);
  }

  return chunks;
}

/**
 * Process markdown content into chunks ready for embedding
 * Handles both heading-based splitting and large chunk fallback
 */
export function processContent(content: string, maxTokens = 800): Chunk[] {
  const headingChunks = chunkMarkdown(content, maxTokens);
  const finalChunks: Chunk[] = [];

  for (const chunk of headingChunks) {
    if (chunk.tokens > maxTokens) {
      // Split large chunk into smaller pieces
      const pieces = splitLargeChunk(chunk.text, maxTokens);
      for (const piece of pieces) {
        finalChunks.push({
          text: piece,
          heading: chunk.heading,
          tokens: estimateTokens(piece),
        });
      }
    } else {
      finalChunks.push(chunk);
    }
  }

  return finalChunks;
}

retrieval.ts

Path: starbot_api/src/services/retrieval.ts

Size: 7300 bytes

/**
 * Retrieval Service
 * Performs semantic search using cosine similarity
 * Includes early-exit optimization for performance
 */

import { prisma } from '../db.js';
import { generateEmbedding } from './embeddings.js';

export interface RetrievalResult {
  chunkId: string;
  text: string;
  similarity: number;
  memoryId: string;
  scope: string;
}

/**
 * Calculate cosine similarity between two vectors
 */
function cosineSimilarity(a: number[], b: number[]): number {
  if (a.length !== b.length) {
    throw new Error('Vectors must have the same length');
  }

  let dotProduct = 0;
  let normA = 0;
  let normB = 0;

  for (let i = 0; i < a.length; i++) {
    dotProduct += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }

  normA = Math.sqrt(normA);
  normB = Math.sqrt(normB);

  if (normA === 0 || normB === 0) {
    return 0;
  }

  return dotProduct / (normA * normB);
}

function formatScope(scope: string): string {
  if (scope === 'project') return 'Project';
  if (scope === 'workspace') return 'Workspace';
  if (scope === 'chat') return 'Chat';
  if (scope === 'identity') return 'Identity';
  return scope;
}

async function searchMemoryDocs(
  docs: Array<{
    id: string;
    scope: string;
    content: string;
    chunks: Array<{ id: string; text: string; embeddingVector: string | null }>;
  }>,
  query: string,
  topK: number,
  minSimilarity: number
): Promise<RetrievalResult[]> {
  const queryEmbedding = await generateEmbedding(query);
  if (!queryEmbedding) {
    return [];
  }

  const results: RetrievalResult[] = [];

  for (const doc of docs) {
    for (const chunk of doc.chunks) {
      if (!chunk.embeddingVector) continue;

      try {
        const chunkEmbedding = JSON.parse(chunk.embeddingVector);
        const similarity = cosineSimilarity(queryEmbedding, chunkEmbedding);

        if (similarity >= minSimilarity) {
          results.push({
            chunkId: chunk.id,
            text: chunk.text,
            similarity,
            memoryId: doc.id,
            scope: doc.scope,
          });

          // Early exit optimization: if we have enough high-quality results, stop scanning
          if (results.length >= topK * 2 && similarity > 0.8) {
            break;
          }
        }
      } catch (error) {
        console.error(`Error parsing embedding for chunk ${chunk.id}:`, error);
      }
    }
  }

  results.sort((a, b) => b.similarity - a.similarity);
  return results.slice(0, topK);
}

/**
 * Search for relevant memory chunks using semantic similarity
 *
 * @param query - The search query text
 * @param projectId - Project ID to scope search
 * @param workspaceId - Optional workspace ID to scope search
 * @param topK - Number of top results to return
 * @param minSimilarity - Minimum similarity threshold (0-1)
 */
export async function searchMemory(
  query: string,
  projectId: string,
  workspaceId?: string,
  topK = 5,
  minSimilarity = 0.5
): Promise<RetrievalResult[]> {
  const memoryDocs = await prisma.memoryDocument.findMany({
    where: {
      OR: [
        {
          scope: 'project',
          projectId,
          workspaceId: null,
          chatId: null,
        },
        ...(workspaceId ? [{
          scope: 'workspace',
          projectId: null,
          workspaceId,
          chatId: null,
        }] : []),
      ],
    },
    include: {
      chunks: true,
    },
  });

  return searchMemoryDocs(memoryDocs, query, topK, minSimilarity);
}

export async function searchIdentityMemory(
  query: string,
  topK = 3,
  minSimilarity = 0.35
): Promise<RetrievalResult[]> {
  const identityDocs = await prisma.memoryDocument.findMany({
    where: {
      scope: 'identity',
      projectId: null,
      workspaceId: null,
      chatId: null,
    },
    include: {
      chunks: true,
    },
    take: 1,
  });

  return searchMemoryDocs(identityDocs, query, topK, minSimilarity);
}

export async function searchChatMemory(
  query: string,
  chatId: string,
  topK = 5,
  minSimilarity = 0.5
): Promise<RetrievalResult[]> {
  const chatDocs = await prisma.memoryDocument.findMany({
    where: {
      scope: 'chat',
      chatId,
      projectId: null,
      workspaceId: null,
    },
    include: {
      chunks: true,
    },
    take: 1,
  });

  return searchMemoryDocs(chatDocs, query, topK, minSimilarity);
}

/**
 * Get relevant context from memory for a chat
 * Combines project and workspace memory (legacy behavior)
 */
export async function getRelevantContext(
  query: string,
  projectId: string,
  workspaceId?: string,
  maxChunks = 5
): Promise<string> {
  const results = await searchMemory(query, projectId, workspaceId, maxChunks);

  if (results.length === 0) {
    return '';
  }

  let context = '# Relevant Memory\n\n';

  for (const result of results) {
    context += `## From ${formatScope(result.scope)} Memory (${(result.similarity * 100).toFixed(1)}% match)\n\n`;
    context += `${result.text}\n\n`;
  }

  return context;
}

export async function getIdentityContext(query: string, maxChunks = 3): Promise<string> {
  const identityDoc = await prisma.memoryDocument.findFirst({
    where: {
      scope: 'identity',
      projectId: null,
      workspaceId: null,
      chatId: null,
    },
  });

  if (!identityDoc) {
    return '';
  }

  const results = await searchIdentityMemory(query, maxChunks);
  if (results.length === 0) {
    return identityDoc.content;
  }

  let context = '# IDENTITY.md\n\n';
  for (const result of results) {
    context += `${result.text}\n\n`;
  }

  return context;
}

export async function getChatMemoryContext(
  query: string,
  chatId: string,
  maxChunks = 5
): Promise<string> {
  const chatMemory = await prisma.memoryDocument.findFirst({
    where: {
      scope: 'chat',
      chatId,
      projectId: null,
      workspaceId: null,
    },
  });

  if (!chatMemory) {
    return '';
  }

  const results = await searchChatMemory(query, chatId, maxChunks);
  if (results.length === 0) {
    return chatMemory.content;
  }

  let context = '# MEMORY.md\n\n';
  for (const result of results) {
    context += `${result.text}\n\n`;
  }

  return context;
}

/**
 * Search within a specific memory document
 */
export async function searchInMemory(
  memoryId: string,
  query: string,
  topK = 3,
  minSimilarity = 0.5
): Promise<RetrievalResult[]> {
  const queryEmbedding = await generateEmbedding(query);
  if (!queryEmbedding) {
    return [];
  }

  const chunks = await prisma.memoryChunk.findMany({
    where: { memoryId },
    include: {
      memory: true,
    },
  });

  const results: RetrievalResult[] = [];

  for (const chunk of chunks) {
    if (!chunk.embeddingVector) {
      continue;
    }

    try {
      const chunkEmbedding = JSON.parse(chunk.embeddingVector);
      const similarity = cosineSimilarity(queryEmbedding, chunkEmbedding);

      if (similarity >= minSimilarity) {
        results.push({
          chunkId: chunk.id,
          text: chunk.text,
          similarity,
          memoryId: chunk.memory.id,
          scope: chunk.memory.scope,
        });
      }
    } catch (error) {
      console.error(`Error parsing embedding for chunk ${chunk.id}:`, error);
    }
  }

  results.sort((a, b) => b.similarity - a.similarity);
  return results.slice(0, topK);
}

message-preprocessor.ts

Path: starbot_api/src/services/message-preprocessor.ts

Size: 18726 bytes

/**
 * Message Pre-Processor Service
 *
 * EVERY user message goes through this before routing.
 * Uses Codex Mini (cheap, fast) to:
 * 1. Classify intent
 * 2. Identify required tools/models
 * 3. Extract entities
 * 4. Attach structured header for downstream routing + memory/search
 */

import { env } from '../env.js';
import { getProvider } from '../providers/index.js';

// ============================================================================
// PROTOCOL DEFINITION
// ============================================================================

export interface StarBotMeta {
  // Primary intent classification
  intent: string;           // e.g., "filesystem/create", "shell/run", "chat/explain"

  // Tool routing
  tools: string[];          // e.g., ["fs-write-file", "fs-glob"]

  // Model selection hint
  model_tier: 'quick' | 'standard' | 'deep';

  // Extracted entities from the message
  entities: Record<string, string | string[] | undefined>;

  // How confident is the classification (0-1)
  confidence: number;

  // Human-readable summary of what the user wants
  context_hint: string;

  // Original user message (preserved)
  original_message: string;

  // Timestamp for cataloging
  timestamp: string;

  // Optional: conversation context summary (for multi-turn)
  conversation_context?: string;
}

export interface PreprocessedMessage {
  meta: StarBotMeta;
  enriched_message: string;  // Original message with header attached
  raw_message: string;       // Original message unchanged
}

// ============================================================================
// AVAILABLE TOOLS & MODELS (for the pre-processor prompt)
// ============================================================================

const AVAILABLE_TOOLS = `
FILESYSTEM TOOLS:
- fs-write-file: Create or overwrite a file with content
- fs-edit-file: Edit specific parts of an existing file
- fs-read-file: Read contents of a file
- fs-glob: Find files matching a pattern (e.g., "*.ts", "src/**/*.js")
- fs-grep: Search for text/patterns within files
- fs-advanced-ops: Move, copy, rename, delete files/directories

SHELL TOOLS:
- shell-exec: Run terminal commands (git, npm, build, etc.)

CODE TOOLS:
- code-exec: Execute code snippets (Python, JS, etc.)

WEB TOOLS:
- web-search: Search the internet for information

CALCULATOR:
- calculator: Evaluate math expressions
`;

const MODEL_TIERS = `
MODEL TIERS:
- quick: Simple tasks, fast responses (listing files, simple questions, file creation)
- standard: Medium complexity (code explanation, moderate analysis)
- deep: Complex tasks (debugging, architecture design, long code generation)
`;

// ============================================================================
// THE PRE-PROCESSOR PROMPT
// ============================================================================

const PREPROCESSOR_SYSTEM_PROMPT = `You are a message pre-processor for StarBot, an AI coding assistant.

YOUR JOB: Analyze every user message and output a JSON classification header.
⚠️ CRITICAL: ALWAYS EXECUTE. NEVER ask for clarification unless the message is literally gibberish.

${AVAILABLE_TOOLS}

${MODEL_TIERS}

OUTPUT FORMAT (JSON only, no markdown):
{
  "intent": "category/action",
  "tools": ["tool1", "tool2"],
  "model_tier": "quick|standard|deep",
  "entities": {"key": "value"},
  "confidence": 0.0-1.0,
  "context_hint": "brief description of what user wants"
}

INTENT CATEGORIES:
- filesystem/list: List directory contents ("ls", "what's in X", "show files", "look into X")
- filesystem/create: Create new file ("create html file", "new python script", "make a file", "just an html doc")
- filesystem/read: Read file contents ("show me X.ts", "cat file", "open file")
- filesystem/edit: Modify existing file ("change X", "update the function", "edit file")
- filesystem/search: Find files or content ("find all .ts files", "search for X", "grep")
- filesystem/navigate: Navigate directories ("parent dir", "go up", "cd ..", "directory above")
- shell/run: Execute commands ("run npm build", "git status", "npm install")
- code/generate: Write new code ("write a function that...", "create a class")
- code/explain: Explain code ("what does this do", "explain this function")
- code/debug: Fix bugs ("why doesn't this work", "fix the error")
- chat/explain: General knowledge ("what is X", "how does Y work")
- chat/general: Casual conversation, greetings
- web/search: Internet lookup ("latest news on", "search for")

ENTITY EXTRACTION:
- file_type: html, css, js, ts, py, json, md, txt, yaml, sh, etc.
- file_path: any mentioned file paths
- folder_name: any mentioned folder/directory names (scripts, src, deploy, lib, etc.)
- command: shell commands to run
- search_query: what to search for
- language: programming language mentioned

🔥 AGGRESSIVE MATCHING RULES - SNAP TO INTENT:

1. FILE CREATION - if ANY of these patterns, intent = filesystem/create:
   - "create/make/new/generate [anything] file/doc/script"
   - "just a/an [filetype]" → e.g., "just an html doc" = create HTML
   - Single file type word: "html", "python", "css" after discussion of creating
   - "create a new one" = create (infer type from context or use txt)
   - "program", "script", "document" = create

2. DIRECTORY LISTING - if ANY of these patterns, intent = filesystem/list:
   - "what's in X", "whats in X", "what is in X"
   - "look into X", "look at X", "look in X", "check X"
   - "show me X", "show X" (where X is folder name)
   - Bare folder names: "scripts", "src", "deploy", "lib", "dist", "node_modules"
   - "ls", "ls X", "list X"

3. DIRECTORY NAVIGATION - intent = filesystem/navigate:
   - "parent", "above", "go up", "one up", ".."
   - "directory above", "folder above"

4. SHELL COMMANDS - intent = shell/run:
   - Starts with: npm, yarn, git, docker, kubectl, cargo, pip, python
   - Contains: run, build, test, install, deploy, compile

CONFIDENCE GUIDE:
- 0.95+: Clear match (create html file, ls src, npm install)
- 0.85+: Good match with minor inference (just an html, look in scripts)
- 0.70+: Reasonable guess (create a new one, look at that)
- 0.50+: Uncertain but actionable (vague verbs, pronouns without context)
- <0.50: Only for actual gibberish

EXAMPLES:

"just an html doc"
{"intent":"filesystem/create","tools":["fs-write-file"],"model_tier":"quick","entities":{"file_type":"html"},"confidence":0.95,"context_hint":"Create new HTML file"}

"what's in scripts"
{"intent":"filesystem/list","tools":[],"model_tier":"quick","entities":{"folder_name":"scripts"},"confidence":0.98,"context_hint":"List scripts folder contents"}

"look into src"
{"intent":"filesystem/list","tools":[],"model_tier":"quick","entities":{"folder_name":"src"},"confidence":0.95,"context_hint":"List src folder contents"}

"directory above"
{"intent":"filesystem/navigate","tools":[],"model_tier":"quick","entities":{"direction":"parent"},"confidence":0.95,"context_hint":"Navigate to parent directory"}

"create a new one"
{"intent":"filesystem/create","tools":["fs-write-file"],"model_tier":"quick","entities":{},"confidence":0.75,"context_hint":"Create new file (type to be determined)"}

"npm test"
{"intent":"shell/run","tools":["shell-exec"],"model_tier":"quick","entities":{"command":"npm test"},"confidence":0.99,"context_hint":"Run npm test"}

"html"
{"intent":"filesystem/create","tools":["fs-write-file"],"model_tier":"quick","entities":{"file_type":"html"},"confidence":0.85,"context_hint":"Create HTML file (inferred from context)"}

"explain how async works"
{"intent":"chat/explain","tools":[],"model_tier":"standard","entities":{"topic":"async"},"confidence":0.95,"context_hint":"Explain async concept"}

Now classify:`;

// ============================================================================
// CORE FUNCTIONS
// ============================================================================

function parsePreprocessorResponse(response: string): Partial<StarBotMeta> | null {
  try {
    // Try to extract JSON from response
    const jsonMatch = response.match(/\{[\s\S]*\}/);
    if (!jsonMatch) return null;

    const parsed = JSON.parse(jsonMatch[0]);
    return {
      intent: parsed.intent || 'chat/general',
      tools: Array.isArray(parsed.tools) ? parsed.tools : [],
      model_tier: ['quick', 'standard', 'deep'].includes(parsed.model_tier) ? parsed.model_tier : 'standard',
      entities: typeof parsed.entities === 'object' ? parsed.entities : {},
      confidence: typeof parsed.confidence === 'number' ? Math.max(0, Math.min(1, parsed.confidence)) : 0.5,
      context_hint: parsed.context_hint || '',
    };
  } catch {
    return null;
  }
}

function buildFallbackMeta(message: string): Partial<StarBotMeta> {
  // Heuristic fallback when LLM fails - BE AGGRESSIVE
  const lower = message.toLowerCase().trim();
  const original = message.trim();

  // 1. FILE CREATION - very aggressive matching
  const fileTypes = 'html|htm|css|js|javascript|ts|typescript|py|python|json|yaml|yml|md|markdown|txt|text|xml|csv|sh|bash|sql|go|rust|rs|java|c|cpp|rb|ruby|php|swift|kotlin';
  const createPatterns = [
    // "create a/an X file", "make new X", etc.
    new RegExp(`\\b(create|make|new|generate|write)\\s+(a\\s+)?(an\\s+)?(new\\s+)?(${fileTypes})\\s*(file|doc|document|script|page)?\\b`, 'i'),
    // "just a/an X"
    new RegExp(`\\bjust\\s+(a|an)\\s+(${fileTypes})\\s*(file|doc|document|script|page)?\\b`, 'i'),
    // Bare file type with context: "html", "python" (when short message)
    new RegExp(`^(${fileTypes})\\s*(file|doc)?$`, 'i'),
    // "create a file/program/script/document"
    /\b(create|make|new)\s+(a\s+)?(an\s+)?(new\s+)?(file|program|script|document|doc|page)\b/i,
    // "create a new one"
    /\b(create|make)\s+(a\s+)?(new\s+)?one\b/i,
  ];

  for (const pattern of createPatterns) {
    if (pattern.test(lower)) {
      const fileTypeMatch = lower.match(new RegExp(`\\b(${fileTypes})\\b`, 'i'));
      return {
        intent: 'filesystem/create',
        tools: ['fs-write-file'],
        model_tier: 'quick',
        entities: fileTypeMatch ? { file_type: fileTypeMatch[1].toLowerCase() } : {},
        confidence: fileTypeMatch ? 0.9 : 0.75,
        context_hint: fileTypeMatch
          ? `Create new ${fileTypeMatch[1].toUpperCase()} file`
          : 'Create new file (type to be determined)',
      };
    }
  }

  // 2. DIRECTORY NAVIGATION - parent, above, go up
  if (/\b(parent|above|go\s*up|one\s*up|\.\.)\b/i.test(lower) ||
      /\b(directory|folder|dir)\s*(above|up)\b/i.test(lower)) {
    return {
      intent: 'filesystem/navigate',
      tools: [],
      model_tier: 'quick',
      entities: { direction: 'parent' },
      confidence: 0.9,
      context_hint: 'Navigate to parent directory',
    };
  }

  // 3. DIRECTORY LISTING - what's in X, look into X, show X, ls X
  const listPatterns = [
    /\b(what'?s?\s+in|whats\s+in|what\s+is\s+in)\s+(.+)$/i,
    /\b(look\s*(in|into|at)|check|show\s*(me)?)\s+(the\s+)?(\w+)\s*(folder|directory|dir)?$/i,
    /^ls\s+(.+)$/i,
    /^ls$/i,
  ];

  // Common folder names
  const folderNames = ['scripts', 'src', 'deploy', 'lib', 'dist', 'build', 'node_modules', 'config', 'test', 'tests', 'spec', 'docs', 'public', 'assets', 'static', 'components', 'services', 'utils', 'helpers', 'routes', 'views', 'models', 'controllers'];

  for (const pattern of listPatterns) {
    const match = original.match(pattern);
    if (match) {
      // Extract the folder name from the match
      const lastGroup = match[match.length - 1]?.trim() || match[1]?.trim();
      const folderMatch = lastGroup?.match(/\b(\w+)\b/);
      const folder = folderMatch?.[1];

      return {
        intent: 'filesystem/list',
        tools: [],
        model_tier: 'quick',
        entities: folder ? { folder_name: folder } : {},
        confidence: 0.9,
        context_hint: folder ? `List contents of ${folder} folder` : 'List current directory',
      };
    }
  }

  // Check for bare folder name mention (e.g., just "scripts" or "src")
  const singleWordFolder = folderNames.find(f => lower === f || lower === f + '/');
  if (singleWordFolder) {
    return {
      intent: 'filesystem/list',
      tools: [],
      model_tier: 'quick',
      entities: { folder_name: singleWordFolder },
      confidence: 0.8,
      context_hint: `List contents of ${singleWordFolder} folder`,
    };
  }

  // 4. SHELL COMMANDS - npm, yarn, git, etc.
  const shellPrefixes = ['npm', 'yarn', 'pnpm', 'git', 'docker', 'kubectl', 'cargo', 'pip', 'python', 'node', 'bun', 'deno'];
  if (shellPrefixes.some(p => lower.startsWith(p + ' ') || lower === p)) {
    return {
      intent: 'shell/run',
      tools: ['shell-exec'],
      model_tier: 'quick',
      entities: { command: original },
      confidence: 0.95,
      context_hint: `Run command: ${original}`,
    };
  }

  if (/\b(run|execute|build|test|install|deploy|compile)\b/i.test(lower)) {
    return {
      intent: 'shell/run',
      tools: ['shell-exec'],
      model_tier: 'quick',
      entities: {},
      confidence: 0.75,
      context_hint: 'Run shell command',
    };
  }

  // 5. WEB SEARCH
  if (/\b(search|google|look\s*up|find\s+online|latest|news|current)\b/i.test(lower) &&
      !lower.includes('file') && !lower.includes('folder')) {
    return {
      intent: 'web/search',
      tools: ['web-search'],
      model_tier: 'quick',
      entities: { search_query: original },
      confidence: 0.8,
      context_hint: 'Web search',
    };
  }

  // 6. DEFAULT: Chat/general - but still high confidence to avoid clarification
  return {
    intent: 'chat/general',
    tools: [],
    model_tier: 'standard',
    entities: {},
    confidence: 0.7, // High enough to avoid clarification
    context_hint: 'General conversation or query',
  };
}

function formatMetaHeader(meta: StarBotMeta): string {
  return [
    '[STARBOT-META]',
    `intent: ${meta.intent}`,
    `tools: [${meta.tools.join(', ')}]`,
    `model_tier: ${meta.model_tier}`,
    `entities: ${JSON.stringify(meta.entities)}`,
    `confidence: ${meta.confidence}`,
    `context_hint: ${meta.context_hint}`,
    `timestamp: ${meta.timestamp}`,
    '[/STARBOT-META]',
  ].join('\n');
}

// ============================================================================
// MAIN EXPORT
// ============================================================================

export async function preprocessMessage(
  message: string,
  conversationContext?: string,
): Promise<PreprocessedMessage> {
  const timestamp = new Date().toISOString();

  let metaPartial: Partial<StarBotMeta>;

  // Try LLM classification
  if (env.INTERPRETER_ENABLED) {
    try {
      const provider = getProvider('cloudflare');
      const response = await provider.sendChat(
        [
          { role: 'system', content: PREPROCESSOR_SYSTEM_PROMPT },
          { role: 'user', content: message },
        ],
        {
          model: env.INTERPRETER_MODEL,
          maxTokens: 300,
          temperature: 0.1,
        },
      );

      const parsed = parsePreprocessorResponse(response.content);
      metaPartial = parsed || buildFallbackMeta(message);
    } catch {
      metaPartial = buildFallbackMeta(message);
    }
  } else {
    metaPartial = buildFallbackMeta(message);
  }

  // Build complete meta object
  const meta: StarBotMeta = {
    intent: metaPartial.intent || 'chat/general',
    tools: metaPartial.tools || [],
    model_tier: metaPartial.model_tier || 'standard',
    entities: metaPartial.entities || {},
    confidence: metaPartial.confidence || 0.5,
    context_hint: metaPartial.context_hint || '',
    original_message: message,
    timestamp,
    conversation_context: conversationContext,
  };

  // Build enriched message with header
  const header = formatMetaHeader(meta);
  const enriched_message = `${header}\n\n${message}`;

  return {
    meta,
    enriched_message,
    raw_message: message,
  };
}

// ============================================================================
// UTILITY: Parse header from enriched message
// ============================================================================

export function parseMetaFromMessage(message: string): StarBotMeta | null {
  const headerMatch = message.match(/\[STARBOT-META\]([\s\S]*?)\[\/STARBOT-META\]/);
  if (!headerMatch) return null;

  const headerContent = headerMatch[1];

  try {
    const intentMatch = headerContent.match(/intent:\s*(.+)/);
    const toolsMatch = headerContent.match(/tools:\s*\[([^\]]*)\]/);
    const tierMatch = headerContent.match(/model_tier:\s*(\w+)/);
    const entitiesMatch = headerContent.match(/entities:\s*(\{.*\})/);
    const confidenceMatch = headerContent.match(/confidence:\s*([\d.]+)/);
    const contextMatch = headerContent.match(/context_hint:\s*(.+)/);
    const timestampMatch = headerContent.match(/timestamp:\s*(.+)/);

    const tools = toolsMatch?.[1]
      ? toolsMatch[1].split(',').map(t => t.trim()).filter(Boolean)
      : [];

    return {
      intent: intentMatch?.[1]?.trim() || 'chat/general',
      tools,
      model_tier: (tierMatch?.[1]?.trim() as 'quick' | 'standard' | 'deep') || 'standard',
      entities: entitiesMatch?.[1] ? JSON.parse(entitiesMatch[1]) : {},
      confidence: confidenceMatch?.[1] ? parseFloat(confidenceMatch[1]) : 0.5,
      context_hint: contextMatch?.[1]?.trim() || '',
      original_message: message.replace(/\[STARBOT-META\][\s\S]*?\[\/STARBOT-META\]\n*/g, '').trim(),
      timestamp: timestampMatch?.[1]?.trim() || new Date().toISOString(),
    };
  } catch {
    return null;
  }
}

// ============================================================================
// UTILITY: Quick intent check helpers
// ============================================================================

export function isFilesystemIntent(meta: StarBotMeta): boolean {
  return meta.intent.startsWith('filesystem/');
}

export function isShellIntent(meta: StarBotMeta): boolean {
  return meta.intent.startsWith('shell/');
}

export function isCodeIntent(meta: StarBotMeta): boolean {
  return meta.intent.startsWith('code/');
}

export function isChatIntent(meta: StarBotMeta): boolean {
  return meta.intent.startsWith('chat/');
}

export function isWebIntent(meta: StarBotMeta): boolean {
  return meta.intent.startsWith('web/');
}

export function isBrowseIntent(meta: StarBotMeta): boolean {
  return meta.intent.startsWith('web/') || meta.intent === 'browse' || meta.intent.includes('search');
}

export function getIntentAction(meta: StarBotMeta): string {
  const parts = meta.intent.split('/');
  return parts[1] || 'unknown';
}

export function getIntentCategory(meta: StarBotMeta): string {
  const parts = meta.intent.split('/');
  return parts[0] || 'unknown';
}

codex-router.ts

Path: starbot_api/src/services/codex-router.ts

Size: 8228 bytes

// Codex Header Routing Protocol
// Every prompt passes through GPT-5.1 Codex Mini first, which stamps it with a structured
// header for downstream model routing. Replaces interpreter + triage when enabled.

import { env } from '../env.js';
import { getProvider } from '../providers/index.js';
import type { InterpreterIntent } from './interpreter.js';

export interface CodexHeader {
  intent: InterpreterIntent;
  category: string;
  complexity: number;
  lane: 'quick' | 'standard' | 'deep';
  tier: number;
  tools: string[];
  contextNeeds: string[];
  confidence: number;
  reasoning: string;
  safety: string;
}

const HEADER_START = '<<<STARBOT_HEADER';
const HEADER_END = '>>>';

const CODEX_SYSTEM_PROMPT = `You are the Starbot routing layer. Analyze the user message and return ONLY a structured header block — nothing else.

FORMAT (return exactly this structure):
<<<STARBOT_HEADER
intent: <chat|browse|filesystem|code|shell|tool|clarify>
category: <CHAT_QA|CODE_CHANGE|DEBUG|CODE_EXPLAIN|BRAINSTORM|WRITE_REWRITE|SUMMARIZE|EXTRACT_STRUCTURE|RESEARCH_COMPARE|PLAN_DESIGN|CLI_OPS|META_TRACE>
complexity: <1-5>
lane: <quick|standard|deep>
tier: <1|2|3>
tools: [<comma-separated tool names or empty>]
context_needs: [<comma-separated: workspace_memory, project_memory, identity, web_search, tasks>]
confidence: <0.0-1.0>
reasoning: <one line explanation>
safety: <safety notes or "none">
>>>

RULES:
- intent=clarify: user message is ambiguous or empty, needs clarification before routing
- intent=browse: user wants web search / current info / research
- intent=filesystem: user wants file operations (read, write, edit, glob, grep, list)
- intent=code: user wants code generation, debugging, refactoring
- intent=shell: user wants to run terminal commands (build, test, install, git)
- intent=tool: user wants a specific tool action
- intent=chat: general conversation, Q&A, explanations (default)

CATEGORY MAPPING:
- CHAT_QA: general questions, explanations
- CODE_CHANGE: writing or modifying code
- DEBUG: fixing bugs, troubleshooting
- CODE_EXPLAIN: explaining existing code
- BRAINSTORM: ideation, creative thinking
- WRITE_REWRITE: prose writing, rewriting text
- SUMMARIZE: condensing content
- EXTRACT_STRUCTURE: parsing, structuring data
- RESEARCH_COMPARE: comparing options, research
- PLAN_DESIGN: architecture, system design
- CLI_OPS: terminal/shell operations
- META_TRACE: meta questions about the assistant itself

TIER LOGIC:
- tier 1: simple Q&A, quick lookups, trivial tasks (lane=quick)
- tier 2: standard coding, moderate complexity (lane=standard)
- tier 3: deep reasoning, architecture, multi-file changes (lane=deep)

AVAILABLE TOOLS:
- web_search: search the web
- calculator: math evaluation
- code_exec: execute code snippets
- file_read: read file contents
- fs_write_file: write/create files
- fs_edit_file: search-and-replace in files
- fs_glob: find files by pattern
- fs_grep: search content in files
- shell_exec: run shell commands
- fs_advanced_ops: advanced file operations (copy, move, delete, etc.)

Only include tools the user's request actually needs. Most chat/QA requests need no tools.

CONTEXT_NEEDS:
- workspace_memory: inject workspace-specific memory (for code/file tasks within a workspace)
- project_memory: inject project-wide memory
- identity: inject identity/personality context
- web_search: pre-fetch web results for context
- tasks: inject task list context

Return ONLY the header block. No explanation, no extra text.`;

export function serializeHeader(header: CodexHeader): string {
  return [
    HEADER_START,
    `intent: ${header.intent}`,
    `category: ${header.category}`,
    `complexity: ${header.complexity}`,
    `lane: ${header.lane}`,
    `tier: ${header.tier}`,
    `tools: [${header.tools.join(', ')}]`,
    `context_needs: [${header.contextNeeds.join(', ')}]`,
    `confidence: ${header.confidence}`,
    `reasoning: ${header.reasoning}`,
    `safety: ${header.safety}`,
    HEADER_END,
  ].join('\n');
}

export function parseHeader(raw: string): CodexHeader | null {
  const startIdx = raw.indexOf(HEADER_START);
  const endIdx = raw.indexOf(HEADER_END, startIdx + HEADER_START.length);
  if (startIdx === -1 || endIdx === -1) return null;

  const block = raw.slice(startIdx + HEADER_START.length, endIdx).trim();
  const lines = block.split('\n').map(l => l.trim()).filter(Boolean);

  const fields: Record<string, string> = {};
  for (const line of lines) {
    const colonIdx = line.indexOf(':');
    if (colonIdx === -1) continue;
    const key = line.slice(0, colonIdx).trim();
    const value = line.slice(colonIdx + 1).trim();
    fields[key] = value;
  }

  const parseArray = (val: string | undefined): string[] => {
    if (!val) return [];
    const inner = val.replace(/^\[/, '').replace(/\]$/, '').trim();
    if (!inner) return [];
    return inner.split(',').map(s => s.trim()).filter(Boolean);
  };

  const intent = normalizeHeaderIntent(fields['intent']);
  const lane = normalizeLane(fields['lane']);
  const tier = clampInt(fields['tier'], 1, 3, 2);
  const complexity = clampInt(fields['complexity'], 1, 5, 3);
  const confidence = clampFloat(fields['confidence'], 0, 1, 0.5);

  return {
    intent,
    category: fields['category'] || 'CHAT_QA',
    complexity,
    lane,
    tier,
    tools: parseArray(fields['tools']),
    contextNeeds: parseArray(fields['context_needs']),
    confidence,
    reasoning: fields['reasoning'] || '',
    safety: fields['safety'] || 'none',
  };
}

export function stripHeader(content: string): string {
  // Remove all <<<STARBOT_HEADER ... >>> blocks from content
  const regex = /<<<STARBOT_HEADER[\s\S]*?>>>/g;
  return content.replace(regex, '').trim();
}

export async function classifyWithCodex(
  userMessage: string,
  conversationContext?: string,
): Promise<CodexHeader> {
  const trimmed = (userMessage || '').trim();

  // Handle empty message
  if (!trimmed) {
    return {
      intent: 'clarify',
      category: 'CHAT_QA',
      complexity: 1,
      lane: 'quick',
      tier: 1,
      tools: [],
      contextNeeds: [],
      confidence: 1.0,
      reasoning: 'Empty message requires clarification',
      safety: 'none',
    };
  }

  try {
    const provider = getProvider('azure');

    const messages: Array<{ role: 'user' | 'system' | 'assistant'; content: string }> = [
      { role: 'system', content: CODEX_SYSTEM_PROMPT },
    ];

    // Include conversation context if provided (e.g. recent messages summary)
    if (conversationContext) {
      messages.push({
        role: 'system',
        content: `Recent conversation context:\n${conversationContext}`,
      });
    }

    messages.push({
      role: 'user',
      content: trimmed,
    });

    const response = await provider.sendChat(messages, {
      model: env.CODEX_ROUTER_MODEL,
      maxTokens: env.CODEX_ROUTER_MAX_TOKENS,
      temperature: 0.1,
    });

    const header = parseHeader(response.content);
    if (!header) {
      throw new Error('Codex response did not contain a valid header block');
    }

    return header;
  } catch (error) {
    // Fallback: return null to signal caller should use legacy pipeline
    throw error;
  }
}

// --- Internal helpers ---

function normalizeHeaderIntent(raw: string | undefined): InterpreterIntent {
  const val = (raw || '').trim().toLowerCase();
  const valid: InterpreterIntent[] = ['chat', 'browse', 'filesystem', 'code', 'shell', 'tool', 'clarify'];
  return (valid.includes(val as InterpreterIntent) ? val : 'chat') as InterpreterIntent;
}

function normalizeLane(raw: string | undefined): 'quick' | 'standard' | 'deep' {
  const val = (raw || '').trim().toLowerCase();
  if (val === 'quick' || val === 'standard' || val === 'deep') return val;
  return 'standard';
}

function clampInt(raw: string | undefined, min: number, max: number, fallback: number): number {
  const num = parseInt(raw || '', 10);
  if (!Number.isFinite(num)) return fallback;
  return Math.max(min, Math.min(max, num));
}

function clampFloat(raw: string | undefined, min: number, max: number, fallback: number): number {
  const num = parseFloat(raw || '');
  if (!Number.isFinite(num)) return fallback;
  return Math.max(min, Math.min(max, num));
}

model-catalog.ts

Path: starbot_api/src/services/model-catalog.ts

Size: 8973 bytes

// Model Catalog for Starbot_API
// Simplified to 2 models: Codex Mini for routing, DeepSeek R1 for chat

import { env, isProviderConfigured } from '../env.js';

export interface ModelDefinition {
  id: string;                    // Unique identifier
  provider: string;              // kimi, vertex, azure, bedrock, cloudflare
  deploymentName: string;        // Actual model name used by provider API
  displayName: string;           // Human-readable name
  tier: number;                  // 1=cheap/fast, 2=standard, 3=premium
  capabilities: string[];        // ['text', 'vision', 'tools', 'streaming']
  contextWindow: number;         // Max input tokens
  maxOutputTokens: number;       // Max output tokens
  costPer1kInput?: number;       // USD per 1k input tokens
  costPer1kOutput?: number;      // USD per 1k output tokens
  latencyMs?: number;            // Typical latency
  status: 'enabled' | 'disabled' | 'beta';
  notes?: string;
}

const MODELS: ModelDefinition[] = [
  // ===== CURRENTLY USING: DEEPSEEK R1 (OFFICIAL API) =====

  // Official DeepSeek API - supports function calling!
  {
    id: 'deepseek-chat',
    provider: 'deepseek',
    deploymentName: 'deepseek-chat',
    displayName: 'DeepSeek Chat V3',
    tier: 1,
    capabilities: ['text', 'streaming', 'tools'],
    contextWindow: 131072,
    maxOutputTokens: 8192,
    costPer1kInput: 0.002,
    costPer1kOutput: 0.008,
    latencyMs: 1200,
    status: 'enabled',
    notes: 'Official DeepSeek API - supports function calling',
  },

  // DeepSeek Reasoner (R1) - Reasoning model
  {
    id: 'deepseek-reasoner',
    provider: 'deepseek',
    deploymentName: 'deepseek-reasoner',
    displayName: 'DeepSeek Reasoner (R1)',
    tier: 1,
    capabilities: ['text', 'streaming'],
    contextWindow: 131072,
    maxOutputTokens: 8192,
    costPer1kInput: 0.002,
    costPer1kOutput: 0.008,
    latencyMs: 1500,
    status: 'enabled',
    notes: 'DeepSeek R1 reasoning model - outputs thought process',
  },

  // Azure DeepSeek-R1 (No function calling support)
  {
    id: 'deepseek-r1-azure',
    provider: 'azure',
    deploymentName: 'DeepSeek-R1',
    displayName: 'DeepSeek R1 (Azure)',
    tier: 1,
    capabilities: ['text', 'streaming'],
    contextWindow: 64000,
    maxOutputTokens: 8192,
    costPer1kInput: 0.002,
    costPer1kOutput: 0.008,
    latencyMs: 1200,
    status: 'disabled',
    notes: 'Azure version - does NOT support function calling',
  },

  // ===== SAVED MODEL DATA (NOT IN USE) =====

  // // Azure GPT-4.1 (Saved for future use)
  // {
  //   id: 'gpt-4.1',
  //   provider: 'azure',
  //   deploymentName: 'gpt-4.1',
  //   displayName: 'GPT-4.1',
  //   tier: 3,
  //   capabilities: ['text', 'streaming', 'tools'],
  //   contextWindow: 128000,
  //   maxOutputTokens: 16384,
  //   status: 'disabled',
  // },

  // // Azure GPT-5.1 Codex Mini (Does NOT support chat completions API)
  // {
  //   id: 'gpt-5.1-codex-mini',
  //   provider: 'azure',
  //   deploymentName: 'gpt-5.1-codex-mini',
  //   displayName: 'GPT-5.1 Codex Mini',
  //   tier: 3,
  //   capabilities: ['text', 'streaming', 'tools'],
  //   contextWindow: 128000,
  //   maxOutputTokens: 16384,
  //   status: 'disabled',
  //   notes: 'Does NOT support chat completions API',
  // },

  // // Azure Mistral-Large-3 (Saved for future use)
  // {
  //   id: 'mistral-large-3',
  //   provider: 'azure',
  //   deploymentName: 'Mistral-Large-3',
  //   displayName: 'Mistral Large 3',
  //   tier: 3,
  //   capabilities: ['text', 'streaming', 'tools'],
  //   contextWindow: 128000,
  //   maxOutputTokens: 16384,
  //   status: 'disabled',
  // },

  // // Claude models (Saved for future use)
  // {
  //   id: 'claude-sonnet-4-5',
  //   provider: 'azure',
  //   deploymentName: 'claude-sonnet-4-5',
  //   displayName: 'Claude Sonnet 4-5',
  //   tier: 2,
  //   capabilities: ['text', 'streaming', 'tools', 'vision'],
  //   contextWindow: 200000,
  //   maxOutputTokens: 8192,
  //   status: 'disabled',
  // },
  //   provider: 'vertex',
  //   deploymentName: 'gemini-2.0-flash',
  //   displayName: 'Gemini 2.0 Flash',
  //   tier: 1,
  //   capabilities: ['text', 'streaming', 'tools', 'vision'],
  //   contextWindow: 1000000,
  //   maxOutputTokens: 8192,
  //   costPer1kInput: 0.0, // Vertex billing different
  //   costPer1kOutput: 0.0,
  //   latencyMs: 400,
  //   status: 'disabled',
  // },

  // // Google Vertex Gemini 1.5 Pro
  // {
  //   id: 'gemini-1.5-pro',
  //   provider: 'vertex',
  //   deploymentName: 'gemini-1.5-pro',
  //   displayName: 'Gemini 1.5 Pro',
  //   tier: 2,
  //   capabilities: ['text', 'streaming', 'tools', 'vision'],
  //   contextWindow: 2000000,
  //   maxOutputTokens: 8192,
  //   costPer1kInput: 0.00125,
  //   costPer1kOutput: 0.005,
  //   latencyMs: 900,
  //   status: 'disabled',
  // },

  // // Cloudflare Workers AI (LLama 3)
  // {
  //   id: 'llama-3.1-70b',
  //   provider: 'cloudflare',
  //   deploymentName: '@cf/meta/llama-3.1-70b-instruct',
  //   displayName: 'Llama 3.1 70B',
  //   tier: 2,
  //   capabilities: ['text', 'streaming'],
  //   contextWindow: 128000,
  //   maxOutputTokens: 4096,
  //   costPer1kInput: 0.0, // Workers AI billing different
  //   costPer1kOutput: 0.0,
  //   latencyMs: 600,
  //   status: 'disabled',
  // },

  // // AWS Bedrock Claude 3.5 Sonnet
  // {
  //   id: 'claude-3.5-sonnet',
  //   provider: 'bedrock',
  //   deploymentName: 'anthropic.claude-3-5-sonnet-20241022',
  //   displayName: 'Claude 3.5 Sonnet',
  //   tier: 2,
  //   capabilities: ['text', 'streaming', 'tools', 'vision'],
  //   contextWindow: 200000,
  //   maxOutputTokens: 8192,
  //   costPer1kInput: 0.003,
  //   costPer1kOutput: 0.015,
  //   latencyMs: 1000,
  //   status: 'disabled',
  // },

  // // Kimi (Moonshot AI)
  // {
  //   id: 'kimi-k2',
  //   provider: 'kimi',
  //   deploymentName: 'moonshot-v1-8k',
  //   displayName: 'Kimi K2',
  //   tier: 2,
  //   capabilities: ['text', 'streaming'],
  //   contextWindow: 128000,
  //   maxOutputTokens: 8192,
  //   costPer1kInput: 0.002,
  //   costPer1kOutput: 0.008,
  //   latencyMs: 800,
  //   status: 'disabled',
  // },
];

export interface ListModelsOptions {
  status?: 'enabled' | 'disabled' | 'beta';
  provider?: string;
  tier?: number;
  capability?: string;
  configuredOnly?: boolean; // Only return models from configured providers
}

export async function listModels(options: ListModelsOptions = {}): Promise<ModelDefinition[]> {
  let filtered = [...MODELS];

  if (options.status) {
    filtered = filtered.filter(m => m.status === options.status);
  }

  if (options.provider) {
    filtered = filtered.filter(m => m.provider === options.provider);
  }

  if (options.tier !== undefined) {
    filtered = filtered.filter(m => m.tier === options.tier);
  }

  if (options.capability) {
    filtered = filtered.filter(m => m.capabilities.includes(options.capability!));
  }

  if (options.configuredOnly) {
    filtered = filtered.filter(m => isProviderConfigured(m.provider));
  }

  return filtered;
}

export async function getModelById(id: string): Promise<ModelDefinition | null> {
  return MODELS.find(m => m.id === id) || null;
}

export async function getModelByProviderAndName(
  provider: string,
  deploymentName: string
): Promise<ModelDefinition | null> {
  return MODELS.find(m => m.provider === provider && m.deploymentName === deploymentName) || null;
}

export async function getCheapestModel(capability: string = 'text'): Promise<ModelDefinition | null> {
  const models = await listModels({ status: 'enabled', capability });

  if (models.length === 0) return null;

  return models.sort((a, b) => {
    const aCost = a.costPer1kInput || Number.POSITIVE_INFINITY;
    const bCost = b.costPer1kInput || Number.POSITIVE_INFINITY;
    return aCost - bCost;
  })[0];
}

export async function getBestModelForTier(
  tier: number,
  capability: string = 'text',
  configuredOnly: boolean = true
): Promise<ModelDefinition | null> {
  const models = await listModels({
    status: 'enabled',
    tier,
    capability,
    configuredOnly,
  });

  if (models.length === 0) {
    // Fallback: try adjacent tiers
    if (tier > 1) {
      return getBestModelForTier(tier - 1, capability, configuredOnly);
    }
    return null;
  }

  // Prefer lower cost within same tier
  return models.sort((a, b) => {
    const aCost = a.costPer1kInput || Number.POSITIVE_INFINITY;
    const bCost = b.costPer1kInput || Number.POSITIVE_INFINITY;
    return aCost - bCost;
  })[0];
}

// Get all models, grouped by tier
export async function getModelsByTier(): Promise<Record<number, ModelDefinition[]>> {
  const models = await listModels({ status: 'enabled', configuredOnly: true });
  const byTier: Record<number, ModelDefinition[]> = { 1: [], 2: [], 3: [] };

  for (const model of models) {
    if (!byTier[model.tier]) byTier[model.tier] = [];
    byTier[model.tier].push(model);
  }

  return byTier;
}

calculator-tool.ts

Path: starbot_api/src/services/tools/calculator-tool.ts

Size: 1555 bytes

// Calculator Tool
// Performs mathematical calculations safely using mathjs

import type { ToolDefinition, ToolResult } from './types.js';
import { evaluate } from 'mathjs';

export const calculatorTool: ToolDefinition = {
  name: 'calculator',
  description: 'Perform mathematical calculations. Supports basic arithmetic, algebra, trigonometry, and more. Use this for any mathematical computation.',
  parameters: [
    {
      name: 'expression',
      type: 'string',
      description: 'Mathematical expression to evaluate (e.g., "2 + 2", "sin(pi/2)", "sqrt(16)")',
      required: true,
    },
  ],
  execute: async (args: Record<string, any>): Promise<ToolResult> => {
    try {
      const expression = String(args.expression || '').trim();
      if (!expression) {
        return {
          success: false,
          content: JSON.stringify({ error: 'Expression is required' }),
        };
      }

      // Evaluate the expression
      const result = evaluate(expression);

      // Convert result to string
      const resultStr = typeof result === 'object'
        ? JSON.stringify(result)
        : String(result);

      return {
        success: true,
        content: JSON.stringify({
          expression,
          result: resultStr,
        }),
      };
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      return {
        success: false,
        content: JSON.stringify({
          error: `Failed to evaluate expression: ${errorMessage}`,
        }),
      };
    }
  },
};

shell-exec-tool.ts

Path: starbot_api/src/services/tools/shell-exec-tool.ts

Size: 5134 bytes

import type { ToolDefinition, ToolResult } from './types.js';
import { spawn } from 'child_process';
import * as path from 'path';

const TOOL_NAME = 'shell_exec';

const description = `Execute shell commands in the terminal. Use this to run build commands, tests, git operations, npm/yarn/pnpm commands, or any other command-line operations. Returns stdout, stderr, and exit code.`;

// Blocked commands for security
const BLOCKED_COMMANDS = [
  'rm -rf /',
  'rm -rf /*',
  'mkfs',
  'dd if=',
  ':(){:|:&};:', // Fork bomb
  'chmod 777 /',
  'chown -R',
  '> /dev/sda',
  'wget | sh',
  'curl | sh',
];

// Commands that require confirmation or are high-risk
const WARN_COMMANDS = [
  'rm -rf',
  'rm -r',
  'del ',
  'format',
  'shutdown',
  'reboot',
  'halt',
  'init 0',
  'init 6',
];

interface ShellExecArgs {
  command: string;
  timeout?: number;
  cwd?: string;
}

function isCommandBlocked(command: string): boolean {
  const lower = command.toLowerCase().trim();
  return BLOCKED_COMMANDS.some(blocked => lower.includes(blocked));
}

function isCommandWarnable(command: string): boolean {
  const lower = command.toLowerCase().trim();
  return WARN_COMMANDS.some(warn => lower.startsWith(warn) || lower.includes(` ${warn}`));
}

export const shellExecTool: ToolDefinition = {
  name: TOOL_NAME,
  description,
  parameters: [
    {
      name: 'command',
      type: 'string',
      description: 'Shell command to execute (e.g., "npm run build", "git status", "python script.py")',
      required: true,
    },
    {
      name: 'timeout',
      type: 'number',
      description: 'Maximum execution time in seconds (default: 30, max: 120)',
      required: false,
      default: 30,
    },
    {
      name: 'cwd',
      type: 'string',
      description: 'Working directory for command (relative to workspace, default: workspace root)',
      required: false,
    },
  ],

  async execute(args: Record<string, any>): Promise<ToolResult> {
    const { command, timeout = 30, cwd } = args as ShellExecArgs;

    // Validate timeout
    const safeTimeout = Math.min(Math.max(1, timeout), 120);

    // Security check
    if (isCommandBlocked(command)) {
      return {
        success: false,
        content: JSON.stringify({
          error: 'Command blocked for security reasons',
          blocked: true,
        }),
      };
    }

    // Warn about dangerous commands but still execute
    const isWarnable = isCommandWarnable(command);

    // Determine working directory
    const workspaceRoot = process.env.WORKSPACE_ROOT || process.cwd();
    let workingDir = workspaceRoot;

    if (cwd) {
      const resolvedCwd = path.resolve(workspaceRoot, cwd);
      if (resolvedCwd.startsWith(workspaceRoot)) {
        workingDir = resolvedCwd;
      }
    }

    return new Promise((resolve) => {
      let stdout = '';
      let stderr = '';
      let killed = false;

      // Determine shell based on platform
      const isWindows = process.platform === 'win32';
      const shell = isWindows ? 'cmd.exe' : '/bin/bash';
      const shellArgs = isWindows ? ['/c', command] : ['-c', command];

      const child = spawn(shell, shellArgs, {
        cwd: workingDir,
        env: {
          ...process.env,
          // Limit environment variables for security
          HOME: workingDir,
          PATH: process.env.PATH?.split(':').filter(p => !p.startsWith('/usr/bin')).join(':') || '/usr/local/bin:/usr/bin:/bin',
        },
        timeout: safeTimeout * 1000,
      });

      const timeoutHandle = setTimeout(() => {
        killed = true;
        child.kill('SIGKILL');
        stderr += `\n\n[TIMEOUT] Command exceeded ${safeTimeout} seconds and was killed`;
      }, safeTimeout * 1000);

      child.stdout?.on('data', (data) => {
        const chunk = data.toString();
        // Limit stdout size
        if (stdout.length + chunk.length < 100000) {
          stdout += chunk;
        } else if (stdout.length < 100000) {
          stdout += chunk.slice(0, 100000 - stdout.length);
          stdout += '\n[OUTPUT TRUNCATED]';
        }
      });

      child.stderr?.on('data', (data) => {
        const chunk = data.toString();
        // Limit stderr size
        if (stderr.length + chunk.length < 50000) {
          stderr += chunk;
        }
      });

      child.on('close', (code) => {
        clearTimeout(timeoutHandle);

        const result = {
          command,
          cwd: workingDir,
          exit_code: code,
          stdout: stdout.trim(),
          stderr: stderr.trim(),
          killed,
          warning: isWarnable ? 'Command may be destructive - executed with caution' : undefined,
        };

        resolve({
          success: code === 0 || !killed,
          content: JSON.stringify(result),
        });
      });

      child.on('error', (error) => {
        clearTimeout(timeoutHandle);
        resolve({
          success: false,
          content: JSON.stringify({
            error: `Command execution failed: ${error.message}`,
            command,
            cwd: workingDir,
          }),
        });
      });
    });
  },
};

export default shellExecTool;

file-read-tool.ts

Path: starbot_api/src/services/tools/file-read-tool.ts

Size: 3070 bytes

// File Read Tool
// Reads file contents from workspace with path traversal protection

import type { ToolDefinition, ToolResult } from './types.js';
import { readFile } from 'fs/promises';
import { resolve, normalize } from 'path';

const MAX_FILE_SIZE = 100000; // 100KB max file size
const MAX_LINES = 500; // Max lines to return

function isPathSafe(filePath: string, baseDir: string): boolean {
  const normalized = normalize(resolve(filePath));
  const normalizedBase = normalize(resolve(baseDir));

  // Ensure the resolved path is within baseDir
  return normalized.startsWith(normalizedBase);
}

export const fileReadTool: ToolDefinition = {
  name: 'read_file',
  description: 'Read contents of a file from the current workspace. Useful for reviewing code, configuration, or documentation files.',
  parameters: [
    {
      name: 'file_path',
      type: 'string',
      description: 'Path to the file to read (relative to workspace root)',
      required: true,
    },
    {
      name: 'max_lines',
      type: 'number',
      description: 'Maximum number of lines to return (default: 500)',
      required: false,
      default: 500,
    },
  ],
  execute: async (args: Record<string, any>): Promise<ToolResult> => {
    try {
      const filePath = String(args.file_path || '').trim();
      if (!filePath) {
        return {
          success: false,
          content: JSON.stringify({ error: 'File path is required' }),
        };
      }

      const maxLines = Math.min(500, Math.max(1, parseInt(String(args.max_lines || '500'), 10)));

      // For now, use current working directory as base
      // In a real implementation, this would be the workspace directory
      const baseDir = process.cwd();

      // Check for path traversal attempts
      const fullPath = resolve(baseDir, filePath);
      if (!isPathSafe(filePath, baseDir)) {
        return {
          success: false,
          content: JSON.stringify({ error: 'Access denied: path traversal detected' }),
        };
      }

      // Read file
      const content = await readFile(fullPath, 'utf-8');

      // Check file size
      if (content.length > MAX_FILE_SIZE) {
        return {
          success: false,
          content: JSON.stringify({
            error: `File is too large (${content.length} bytes > ${MAX_FILE_SIZE} bytes)`,
          }),
        };
      }

      // Split into lines and limit
      const lines = content.split('\n');
      const truncated = lines.length > maxLines;
      const limitedLines = lines.slice(0, maxLines);

      return {
        success: true,
        content: JSON.stringify({
          file_path: filePath,
          lines: limitedLines,
          total_lines: lines.length,
          truncated,
          line_count: limitedLines.length,
        }),
      };
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      return {
        success: false,
        content: JSON.stringify({
          error: `Failed to read file: ${errorMessage}`,
        }),
      };
    }
  },
};

fs-smart-edit-tool.ts

Path: starbot_api/src/services/tools/fs-smart-edit-tool.ts

Size: 14454 bytes

/**
 * Smart File Edit Tool - AST-aware editing for code files
 *
 * Supports:
 * - Edit by function/class/method name
 * - Smart indentation preservation
 * - Unified diff generation
 * - Language-aware parsing (TS/JS, Python, Rust, Go)
 */

import type { ToolDefinition, ToolResult } from './types.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const TOOL_NAME = 'fs_smart_edit';

// Language detection by extension
const LANG_MAP: Record<string, string> = {
  '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript',
  '.py': 'python', '.rs': 'rust', '.go': 'go', '.java': 'java',
  '.c': 'c', '.cpp': 'cpp', '.h': 'c', '.hpp': 'cpp',
  '.rb': 'ruby', '.php': 'php', '.swift': 'swift', '.kt': 'kotlin',
};

// AST-like patterns for different constructs
const PATTERNS: Record<string, Record<string, RegExp>> = {
  typescript: {
    function: /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*(?:<[^>]*>)?\s*\([^)]*\)(?:\s*:\s*[^{]+)?\s*\{/g,
    arrow: /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>/g,
    class: /(?:export\s+)?(?:abstract\s+)?class\s+(\w+)(?:\s+extends\s+\w+)?(?:\s+implements\s+[^{]+)?\s*\{/g,
    method: /(?:(?:public|private|protected|static|async|readonly)\s+)*(\w+)\s*(?:<[^>]*>)?\s*\([^)]*\)(?:\s*:\s*[^{]+)?\s*\{/g,
    interface: /(?:export\s+)?interface\s+(\w+)(?:\s+extends\s+[^{]+)?\s*\{/g,
    type: /(?:export\s+)?type\s+(\w+)\s*(?:<[^>]*>)?\s*=/g,
  },
  javascript: {
    function: /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\([^)]*\)\s*\{/g,
    arrow: /(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>/g,
    class: /(?:export\s+)?class\s+(\w+)(?:\s+extends\s+\w+)?\s*\{/g,
    method: /(\w+)\s*\([^)]*\)\s*\{/g,
  },
  python: {
    function: /^(\s*)(?:async\s+)?def\s+(\w+)\s*\([^)]*\)\s*(?:->\s*[^:]+)?:/gm,
    class: /^(\s*)class\s+(\w+)(?:\([^)]*\))?\s*:/gm,
    method: /^(\s+)(?:async\s+)?def\s+(\w+)\s*\(self[^)]*\)\s*(?:->\s*[^:]+)?:/gm,
  },
  rust: {
    function: /(?:pub\s+)?(?:async\s+)?fn\s+(\w+)(?:<[^>]*>)?\s*\([^)]*\)(?:\s*->\s*[^{]+)?\s*\{/g,
    struct: /(?:pub\s+)?struct\s+(\w+)(?:<[^>]*>)?\s*\{/g,
    impl: /impl(?:<[^>]*>)?\s+(?:\w+\s+for\s+)?(\w+)(?:<[^>]*>)?\s*\{/g,
    enum: /(?:pub\s+)?enum\s+(\w+)(?:<[^>]*>)?\s*\{/g,
  },
  go: {
    function: /func\s+(\w+)\s*\([^)]*\)(?:\s*[^{]+)?\s*\{/g,
    method: /func\s+\([^)]+\)\s*(\w+)\s*\([^)]*\)(?:\s*[^{]+)?\s*\{/g,
    struct: /type\s+(\w+)\s+struct\s*\{/g,
    interface: /type\s+(\w+)\s+interface\s*\{/g,
  },
};

interface SmartEditArgs {
  path: string;
  target_type: 'function' | 'class' | 'method' | 'block' | 'line_range';
  target_name?: string;
  line_start?: number;
  line_end?: number;
  new_content: string;
  create_backup?: boolean;
}

interface CodeBlock {
  name: string;
  type: string;
  startLine: number;
  endLine: number;
  startIndex: number;
  endIndex: number;
  content: string;
  indentation: string;
}

function detectLanguage(filePath: string): string {
  const ext = path.extname(filePath).toLowerCase();
  return LANG_MAP[ext] || 'text';
}

function findMatchingBrace(content: string, startIndex: number, openChar = '{', closeChar = '}'): number {
  let depth = 1;
  let i = startIndex;

  // Handle Python (indentation-based)
  if (openChar === ':') {
    const lines = content.slice(startIndex).split('\n');
    const firstLine = lines[0];
    const baseIndent = (firstLine.match(/^(\s*)/) || ['', ''])[1].length;

    let endIndex = startIndex + firstLine.length;
    for (let j = 1; j < lines.length; j++) {
      const line = lines[j];
      const lineIndent = (line.match(/^(\s*)/) || ['', ''])[1].length;

      // Empty lines or comments continue the block
      if (line.trim() === '' || line.trim().startsWith('#')) {
        endIndex += line.length + 1;
        continue;
      }

      // If we hit a line with less or equal indentation, block ends
      if (lineIndent <= baseIndent && line.trim() !== '') {
        return endIndex - 1;
      }
      endIndex += line.length + 1;
    }
    return content.length;
  }

  while (i < content.length && depth > 0) {
    const char = content[i];
    if (char === openChar) depth++;
    else if (char === closeChar) depth--;

    // Skip strings and comments
    if (char === '"' || char === "'") {
      const quote = char;
      i++;
      while (i < content.length && content[i] !== quote) {
        if (content[i] === '\\') i++;
        i++;
      }
    }
    if (char === '/' && content[i + 1] === '/') {
      while (i < content.length && content[i] !== '\n') i++;
    }
    if (char === '/' && content[i + 1] === '*') {
      i += 2;
      while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++;
      i++;
    }

    i++;
  }

  return i;
}

function findCodeBlock(content: string, language: string, targetType: string, targetName: string): CodeBlock | null {
  const langPatterns = PATTERNS[language] || PATTERNS.javascript;
  const pattern = langPatterns[targetType];

  if (!pattern) return null;

  // Reset regex
  pattern.lastIndex = 0;

  let match;
  while ((match = pattern.exec(content)) !== null) {
    // Extract name from match (handle Python's different group structure)
    const name = language === 'python' ? match[2] : match[1];

    if (name === targetName) {
      const startIndex = match.index;
      const startLine = content.slice(0, startIndex).split('\n').length;

      // Find end of block
      let endIndex: number;
      if (language === 'python') {
        endIndex = findMatchingBrace(content, match.index + match[0].length, ':', '');
      } else {
        const braceStart = content.indexOf('{', startIndex);
        if (braceStart === -1) continue;
        endIndex = findMatchingBrace(content, braceStart + 1);
      }

      const endLine = content.slice(0, endIndex).split('\n').length;
      const blockContent = content.slice(startIndex, endIndex);
      const indentation = (content.slice(0, startIndex).split('\n').pop() || '').match(/^(\s*)/)?.[1] || '';

      return {
        name,
        type: targetType,
        startLine,
        endLine,
        startIndex,
        endIndex,
        content: blockContent,
        indentation,
      };
    }
  }

  return null;
}

function generateUnifiedDiff(oldContent: string, newContent: string, filePath: string): string {
  const oldLines = oldContent.split('\n');
  const newLines = newContent.split('\n');

  const diff: string[] = [];
  diff.push(`--- a/${filePath}`);
  diff.push(`+++ b/${filePath}`);

  // Simple line-by-line diff (not optimal but functional)
  let i = 0, j = 0;
  let hunkStart = -1;
  let hunkOldStart = 0, hunkNewStart = 0;
  let hunkLines: string[] = [];

  const flushHunk = () => {
    if (hunkLines.length > 0) {
      diff.push(`@@ -${hunkOldStart + 1},${hunkLines.filter(l => !l.startsWith('+')).length} +${hunkNewStart + 1},${hunkLines.filter(l => !l.startsWith('-')).length} @@`);
      diff.push(...hunkLines);
      hunkLines = [];
    }
  };

  while (i < oldLines.length || j < newLines.length) {
    if (i < oldLines.length && j < newLines.length && oldLines[i] === newLines[j]) {
      // Lines match
      if (hunkLines.length > 0) {
        hunkLines.push(` ${oldLines[i]}`);
        if (hunkLines.filter(l => l.startsWith('+') || l.startsWith('-')).length === 0) {
          flushHunk();
        }
      }
      i++;
      j++;
    } else {
      // Lines differ
      if (hunkLines.length === 0) {
        hunkOldStart = i;
        hunkNewStart = j;
        // Add context
        for (let c = Math.max(0, i - 3); c < i; c++) {
          hunkLines.push(` ${oldLines[c]}`);
        }
      }

      if (i < oldLines.length && (j >= newLines.length || oldLines[i] !== newLines[j])) {
        hunkLines.push(`-${oldLines[i]}`);
        i++;
      }
      if (j < newLines.length && (i >= oldLines.length || oldLines[i] !== newLines[j])) {
        hunkLines.push(`+${newLines[j]}`);
        j++;
      }
    }
  }

  flushHunk();

  return diff.join('\n');
}

function applyIndentation(content: string, baseIndent: string): string {
  const lines = content.split('\n');
  return lines.map((line, i) => {
    if (i === 0 || line.trim() === '') return line;
    return baseIndent + line;
  }).join('\n');
}

function validatePath(filePath: string, workspaceRoot: string): string | null {
  const cleaned = filePath.trim();
  if (path.isAbsolute(cleaned)) return null;

  const normalized = path.normalize(cleaned);
  if (normalized.includes('..')) return null;

  const fullPath = path.resolve(workspaceRoot, normalized);
  if (!fullPath.startsWith(path.resolve(workspaceRoot))) return null;

  return fullPath;
}

export const fsSmartEditTool: ToolDefinition = {
  name: TOOL_NAME,
  description: `Smart code editor with AST-aware editing. Can target specific functions, classes, or methods by name. Generates unified diffs and preserves indentation. Supports TypeScript, JavaScript, Python, Rust, and Go.`,
  parameters: [
    {
      name: 'path',
      type: 'string',
      description: 'File path to edit (relative to workspace)',
      required: true,
    },
    {
      name: 'target_type',
      type: 'string',
      description: 'Type of code block to edit: "function", "class", "method", "block", or "line_range"',
      required: true,
      enum: ['function', 'class', 'method', 'block', 'line_range'],
    },
    {
      name: 'target_name',
      type: 'string',
      description: 'Name of the function/class/method to edit (required unless target_type is "line_range")',
      required: false,
    },
    {
      name: 'line_start',
      type: 'number',
      description: 'Starting line number for line_range edit (1-indexed)',
      required: false,
    },
    {
      name: 'line_end',
      type: 'number',
      description: 'Ending line number for line_range edit (1-indexed, inclusive)',
      required: false,
    },
    {
      name: 'new_content',
      type: 'string',
      description: 'New content to replace the target block with',
      required: true,
    },
    {
      name: 'create_backup',
      type: 'boolean',
      description: 'Create backup before editing (default: true)',
      required: false,
      default: true,
    },
  ],

  async execute(args: Record<string, any>): Promise<ToolResult> {
    const {
      path: filePath,
      target_type,
      target_name,
      line_start,
      line_end,
      new_content,
      create_backup = true,
    } = args as SmartEditArgs;

    const workspaceRoot = process.env.WORKSPACE_ROOT || process.cwd();
    const fullPath = validatePath(filePath, workspaceRoot);

    if (!fullPath) {
      return {
        content: JSON.stringify({ success: false, error: 'Invalid file path' }),
        success: false,
      };
    }

    try {
      const originalContent = await fs.readFile(fullPath, 'utf-8');
      const language = detectLanguage(filePath);

      let newFileContent: string;
      let editedBlock: { startLine: number; endLine: number; name?: string } | null = null;

      if (target_type === 'line_range') {
        // Edit by line range
        if (!line_start || !line_end) {
          return {
            content: JSON.stringify({ success: false, error: 'line_start and line_end required for line_range edit' }),
            success: false,
          };
        }

        const lines = originalContent.split('\n');
        const before = lines.slice(0, line_start - 1);
        const after = lines.slice(line_end);

        newFileContent = [...before, new_content, ...after].join('\n');
        editedBlock = { startLine: line_start, endLine: line_end };

      } else {
        // Edit by AST target
        if (!target_name) {
          return {
            content: JSON.stringify({ success: false, error: 'target_name required for function/class/method edit' }),
            success: false,
          };
        }

        const block = findCodeBlock(originalContent, language, target_type, target_name);

        if (!block) {
          return {
            content: JSON.stringify({
              success: false,
              error: `Could not find ${target_type} "${target_name}" in file`,
              hint: `Make sure the ${target_type} exists and is named exactly "${target_name}"`,
            }),
            success: false,
          };
        }

        // Apply proper indentation to new content
        const indentedContent = applyIndentation(new_content, block.indentation);

        newFileContent =
          originalContent.slice(0, block.startIndex) +
          indentedContent +
          originalContent.slice(block.endIndex);

        editedBlock = {
          startLine: block.startLine,
          endLine: block.endLine,
          name: block.name,
        };
      }

      // Create backup
      let backupPath: string | undefined;
      if (create_backup) {
        const backupDir = path.join(workspaceRoot, '.backups');
        await fs.mkdir(backupDir, { recursive: true });
        const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
        backupPath = path.join(backupDir, `${path.basename(filePath)}.${timestamp}.bak`);
        await fs.writeFile(backupPath, originalContent, 'utf-8');
      }

      // Write new content
      await fs.writeFile(fullPath, newFileContent, 'utf-8');

      // Generate diff
      const diff = generateUnifiedDiff(originalContent, newFileContent, filePath);

      return {
        content: JSON.stringify({
          success: true,
          message: `Successfully edited ${target_type}${target_name ? ` "${target_name}"` : ''} in ${filePath}`,
          language,
          edited_block: editedBlock,
          backup_path: backupPath,
          diff_preview: diff.split('\n').slice(0, 50).join('\n') + (diff.split('\n').length > 50 ? '\n...(truncated)' : ''),
          stats: {
            old_lines: originalContent.split('\n').length,
            new_lines: newFileContent.split('\n').length,
            change: newFileContent.split('\n').length - originalContent.split('\n').length,
          },
        }),
        success: true,
      };
    } catch (error) {
      return {
        content: JSON.stringify({
          success: false,
          error: `Failed to edit file: ${error instanceof Error ? error.message : String(error)}`,
        }),
        success: false,
      };
    }
  },
};

export default fsSmartEditTool;

registry.ts

Path: starbot_api/src/services/tools/registry.ts

Size: 1854 bytes

// Tool Registry - Central registry for all available tools
// Tools are registered on startup and can be dynamically queried

import type { ToolDefinition, ToolParameter } from './types.js';

export interface OpenAIFunctionDef {
  name: string;
  description: string;
  parameters: {
    type: 'object';
    properties: Record<string, any>;
    required: string[];
  };
}

export class ToolRegistry {
  private tools: Map<string, ToolDefinition> = new Map();

  register(tool: ToolDefinition): void {
    if (this.tools.has(tool.name)) {
      console.warn(`Tool "${tool.name}" already registered, overwriting`);
    }
    this.tools.set(tool.name, tool);
  }

  get(name: string): ToolDefinition | undefined {
    return this.tools.get(name);
  }

  getAll(): ToolDefinition[] {
    return Array.from(this.tools.values());
  }

  has(name: string): boolean {
    return this.tools.has(name);
  }

  toOpenAIFunctions(): OpenAIFunctionDef[] {
    return Array.from(this.tools.values()).map(tool => ({
      name: tool.name,
      description: tool.description,
      parameters: {
        type: 'object',
        properties: this.parametersToOpenAISchema(tool.parameters),
        required: tool.parameters.filter(p => p.required).map(p => p.name),
      },
    }));
  }

  private parametersToOpenAISchema(params: ToolParameter[]): Record<string, any> {
    const schema: Record<string, any> = {};

    for (const param of params) {
      const paramSchema: any = {
        type: param.type,
        description: param.description,
      };

      if (param.enum) {
        paramSchema.enum = param.enum;
      }

      if (param.default !== undefined) {
        paramSchema.default = param.default;
      }

      schema[param.name] = paramSchema;
    }

    return schema;
  }
}

// Singleton instance
export const toolRegistry = new ToolRegistry();

fs-grep-tool.ts

Path: starbot_api/src/services/tools/fs-grep-tool.ts

Size: 7111 bytes

import type { ToolDefinition, ToolResult } from './types.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const TOOL_NAME = 'fs_grep';

const description = `Search for text or regex patterns within files. Returns matching lines with file paths and line numbers. Useful for finding function definitions, imports, or any code patterns across your codebase.`;

interface GrepArgs {
  pattern: string;
  path?: string;
  include?: string;
  max_results?: number;
  context_lines?: number;
}

function validatePath(filePath: string, workspaceRoot: string): string | null {
  const cleaned = filePath.trim();
  if (path.isAbsolute(cleaned)) {
    return null;
  }
  const normalized = path.normalize(cleaned);
  if (normalized.includes('..')) {
    return null;
  }
  const fullPath = path.resolve(workspaceRoot, normalized);
  const resolvedWorkspace = path.resolve(workspaceRoot);
  if (!fullPath.startsWith(resolvedWorkspace)) {
    return null;
  }
  return fullPath;
}

// Check if file matches the include pattern
function matchesInclude(filePath: string, include: string | undefined): boolean {
  if (!include) return true;

  const fileName = path.basename(filePath);
  const pattern = include.replace(/\*/g, '.*').replace(/\?/g, '.');
  const regex = new RegExp(`^${pattern}$`, 'i');
  return regex.test(fileName);
}

// Check if file is likely binary
async function isBinaryFile(filePath: string): Promise<boolean> {
  try {
    const buffer = Buffer.alloc(512);
    const fd = await fs.open(filePath, 'r');
    const { bytesRead } = await fd.read(buffer, 0, 512, 0);
    await fd.close();

    // Check for null bytes (common in binary files)
    for (let i = 0; i < bytesRead; i++) {
      if (buffer[i] === 0) {
        return true;
      }
    }
    return false;
  } catch {
    return false;
  }
}

export const fsGrepTool: ToolDefinition = {
  name: TOOL_NAME,
  description,
  parameters: [
    {
      name: 'pattern',
      type: 'string',
      description: 'Search pattern (plain text or regex). For regex, use standard JavaScript regex syntax.',
      required: true,
    },
    {
      name: 'path',
      type: 'string',
      description: 'Directory to search in (relative to workspace, default: workspace root)',
      required: false,
    },
    {
      name: 'include',
      type: 'string',
      description: 'File pattern to include in search (e.g., "*.ts", "*.js", "src/**/*.py")',
      required: false,
    },
    {
      name: 'max_results',
      type: 'number',
      description: 'Maximum number of matching lines to return (default: 100)',
      required: false,
      default: 100,
    },
    {
      name: 'context_lines',
      type: 'number',
      description: 'Number of lines of context to include before/after matches (default: 1)',
      required: false,
      default: 1,
    },
  ],

  async execute(args: Record<string, any>): Promise<ToolResult> {
    const {
      pattern,
      path: searchPath,
      include,
      max_results = 100,
      context_lines = 1,
    } = args as GrepArgs;

    const workspaceRoot = process.env.WORKSPACE_ROOT || process.cwd();
    const basePath = searchPath
      ? validatePath(searchPath, workspaceRoot)
      : workspaceRoot;

    if (!basePath) {
      return {
        success: false,
        content: JSON.stringify({
          error: 'Invalid search path. Path traversal attempts are not allowed.',
        }),
      };
    }

    // Store basePath in a variable that TypeScript knows is a string
    const searchRoot = basePath;

    try {
      // Compile regex
      let regex: RegExp;
      try {
        regex = new RegExp(pattern, 'gi');
      } catch {
        // If invalid regex, treat as literal string
        regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
      }

      const matches: Array<{
        file: string;
        line: number;
        content: string;
      }> = [];
      const maxDepth = 15;
      const maxFiles = 200;

      async function searchDir(dir: string, depth: number): Promise<void> {
        if (depth > maxDepth || matches.length >= max_results) {
          return;
        }

        try {
          const entries = await fs.readdir(dir, { withFileTypes: true });
          let filesSearched = 0;

          for (const entry of entries) {
            if (matches.length >= max_results || filesSearched >= maxFiles) break;

            // Skip hidden directories and common noise
            if (entry.name.startsWith('.') && entry.isDirectory()) {
              continue;
            }
            if (['node_modules', '__pycache__', '.git', 'dist', 'build'].includes(entry.name)) {
              continue;
            }

            const fullPath = path.join(dir, entry.name);

            if (entry.isFile()) {
              // Check include pattern
              if (!matchesInclude(fullPath, include)) {
                continue;
              }

              // Skip binary files
              if (await isBinaryFile(fullPath)) {
                continue;
              }

              filesSearched++;

              try {
                const content = await fs.readFile(fullPath, 'utf-8');
                const lines = content.split('\n');

                for (let i = 0; i < lines.length && matches.length < max_results; i++) {
                  // Reset regex state for each line
                  regex.lastIndex = 0;

                  if (regex.test(lines[i])) {
                    // Get context lines
                    const startLine = Math.max(0, i - context_lines);
                    const endLine = Math.min(lines.length - 1, i + context_lines);
                    const context = lines.slice(startLine, endLine + 1).join('\n');

                    matches.push({
                      file: path.relative(searchRoot, fullPath),
                      line: i + 1, // 1-indexed
                      content: context,
                    });
                  }
                }
              } catch {
                // Skip files we can't read
              }
            } else if (entry.isDirectory()) {
              await searchDir(fullPath, depth + 1);
            }
          }
        } catch {
          // Skip directories we can't read
        }
      }

      await searchDir(searchRoot, 0);

      // Format output
      const output = matches.map(m => {
        const prefix = `File: ${m.file} | Line ${m.line}:`;
        return `${prefix}\n${m.content}`;
      }).join('\n\n---\n\n');

      return {
        success: true,
        content: JSON.stringify({
          pattern,
          path: searchPath || '.',
          include: include || '*',
          matches: output,
          match_count: matches.length,
          truncated: matches.length >= max_results,
        }),
      };
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      return {
        success: false,
        content: JSON.stringify({
          error: `Grep search failed: ${errorMessage}`,
        }),
      };
    }
  },
};

export default fsGrepTool;

fs-glob-tool.ts

Path: starbot_api/src/services/tools/fs-glob-tool.ts

Size: 5592 bytes

import type { ToolDefinition, ToolResult } from './types.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const TOOL_NAME = 'fs_glob';

const description = `Find files matching a glob pattern. Useful for discovering files when you don't know the exact path. Supports patterns like "**/*.ts", "src/**/*.js", "**/test*.py". Searches recursively from the starting directory.`;

interface GlobArgs {
  pattern: string;
  path?: string;
  max_results?: number;
}

function validatePath(filePath: string, workspaceRoot: string): string | null {
  const cleaned = filePath.trim();
  if (path.isAbsolute(cleaned)) {
    return null;
  }
  const normalized = path.normalize(cleaned);
  if (normalized.includes('..')) {
    return null;
  }
  const fullPath = path.resolve(workspaceRoot, normalized);
  const resolvedWorkspace = path.resolve(workspaceRoot);
  if (!fullPath.startsWith(resolvedWorkspace)) {
    return null;
  }
  return fullPath;
}

// Simple glob matching - supports **, *, and ?
function matchesPattern(filePath: string, pattern: string): boolean {
  // Handle ** (recursive wildcard)
  if (pattern.startsWith('**/')) {
    const rest = pattern.slice(3);
    const parts = filePath.split('/');
    for (let i = 0; i < parts.length; i++) {
      const subpath = parts.slice(i).join('/');
      if (matchesPattern(subpath, rest)) return true;
    }
    return matchesPattern(filePath, rest);
  }

  // Split pattern into parts
  const patternParts = pattern.split('/');
  const pathParts = filePath.split('/');

  // If pattern has more parts than path, can't match
  if (patternParts.length > pathParts.length) {
    return false;
  }

  // Match each part
  let pathIndex = 0;
  for (let i = 0; i < patternParts.length; i++) {
    const p = patternParts[i];

    if (p === '*') {
      // Single directory wildcard - matches anything except /
      pathIndex++;
    } else if (p === '**') {
      // Already handled above
      pathIndex++;
    } else if (p.includes('*') || p.includes('?')) {
      // Complex pattern with wildcards
      const regex = new RegExp(
        '^' + p.replace(/\./g, '\\.').replace(/\*/g, '[^/]*').replace(/\?/g, '.') + '$'
      );
      if (!regex.test(pathParts[pathIndex])) {
        return false;
      }
      pathIndex++;
    } else {
      // Exact match
      if (pathParts[pathIndex] !== p) {
        return false;
      }
      pathIndex++;
    }
  }

  return true;
}

export const fsGlobTool: ToolDefinition = {
  name: TOOL_NAME,
  description,
  parameters: [
    {
      name: 'pattern',
      type: 'string',
      description: 'Glob pattern to match files (e.g., "**/*.ts", "src/**/*.js", "**/test*.py")',
      required: true,
    },
    {
      name: 'path',
      type: 'string',
      description: 'Starting directory for search (relative to workspace, default: workspace root)',
      required: false,
    },
    {
      name: 'max_results',
      type: 'number',
      description: 'Maximum number of results to return (default: 50)',
      required: false,
      default: 50,
    },
  ],

  async execute(args: Record<string, any>): Promise<ToolResult> {
    const { pattern, path: searchPath, max_results = 50 } = args as GlobArgs;

    const workspaceRoot = process.env.WORKSPACE_ROOT || process.cwd();
    const basePath = searchPath
      ? validatePath(searchPath, workspaceRoot)
      : workspaceRoot;

    if (!basePath) {
      return {
        success: false,
        content: JSON.stringify({
          error: 'Invalid search path. Path traversal attempts are not allowed.',
        }),
      };
    }

    try {
      const matches: string[] = [];
      const maxDepth = 20; // Prevent infinite recursion

      // Store basePath in a variable that TypeScript knows is a string
      const searchRoot = basePath;

      async function walkDir(dir: string, depth: number): Promise<void> {
        if (depth > maxDepth || matches.length >= max_results) {
          return;
        }

        try {
          const entries = await fs.readdir(dir, { withFileTypes: true });

          for (const entry of entries) {
            if (matches.length >= max_results) break;

            // Skip hidden directories and common noise
            if (entry.name.startsWith('.') && entry.isDirectory()) {
              continue;
            }
            if (['node_modules', '__pycache__', '.git', 'dist', 'build'].includes(entry.name)) {
              continue;
            }

            const fullPath = path.join(dir, entry.name);
            const relativePath = path.relative(searchRoot, fullPath);

            if (entry.isFile()) {
              if (matchesPattern(relativePath, pattern)) {
                matches.push(relativePath);
              }
            } else if (entry.isDirectory()) {
              await walkDir(fullPath, depth + 1);
            }
          }
        } catch {
          // Skip directories we can't read
        }
      }

      await walkDir(searchRoot, 0);

      // Sort results alphabetically
      matches.sort();

      return {
        success: true,
        content: JSON.stringify({
          pattern,
          path: searchPath || '.',
          matches,
          count: matches.length,
          truncated: matches.length >= max_results,
        }),
      };
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      return {
        success: false,
        content: JSON.stringify({
          error: `Glob search failed: ${errorMessage}`,
        }),
      };
    }
  },
};

export default fsGlobTool;

code-exec-tool.ts

Path: starbot_api/src/services/tools/code-exec-tool.ts

Size: 4350 bytes

// Code Execution Tool
// Executes code in a sandboxed environment with timeout protection

import type { ToolDefinition, ToolResult } from './types.js';
import { spawn } from 'child_process';
import { writeFile, unlink, mkdtemp } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';

const EXECUTION_TIMEOUT_MS = 5000; // 5 second timeout
const MAX_OUTPUT_LENGTH = 5000; // Max output chars

async function executeCode(language: string, code: string): Promise<string> {
  const tmpDir = await mkdtemp(join(tmpdir(), 'starbot-exec-'));
  let fileName: string;
  let command: string;
  let args: string[];

  if (language === 'python' || language === 'py') {
    fileName = join(tmpDir, 'script.py');
    command = 'python3';
    args = [fileName];
  } else if (language === 'javascript' || language === 'js') {
    fileName = join(tmpDir, 'script.js');
    command = 'node';
    args = [fileName];
  } else {
    throw new Error(`Unsupported language: ${language}`);
  }

  try {
    // Write code to temp file
    await writeFile(fileName, code, 'utf-8');

    // Execute with timeout
    return await new Promise((resolve, reject) => {
      const process = spawn(command, args, {
        timeout: EXECUTION_TIMEOUT_MS,
        stdio: ['pipe', 'pipe', 'pipe'],
      });

      let stdout = '';
      let stderr = '';

      process.stdout?.on('data', (data) => {
        stdout += data.toString();
        if (stdout.length > MAX_OUTPUT_LENGTH) {
          process.kill();
        }
      });

      process.stderr?.on('data', (data) => {
        stderr += data.toString();
      });

      const timer = setTimeout(() => {
        process.kill();
        reject(new Error('Execution timeout: code took longer than 5 seconds'));
      }, EXECUTION_TIMEOUT_MS);

      process.on('close', (code) => {
        clearTimeout(timer);
        if (code === 0) {
          resolve(stdout.slice(0, MAX_OUTPUT_LENGTH));
        } else {
          reject(new Error(`Exit code ${code}: ${stderr || stdout}`));
        }
      });

      process.on('error', (err) => {
        clearTimeout(timer);
        reject(err);
      });
    });
  } finally {
    // Cleanup temp file
    try {
      await unlink(fileName);
      await unlink(tmpDir);
    } catch {
      // Ignore cleanup errors
    }
  }
}

export const codeExecTool: ToolDefinition = {
  name: 'execute_code',
  description: 'Execute code in a sandboxed environment (Python or JavaScript). Limited to 5 seconds execution time. Use for calculations, data processing, or quick scripts.',
  parameters: [
    {
      name: 'language',
      type: 'string',
      description: 'Programming language: "python" or "javascript"',
      required: true,
      enum: ['python', 'javascript'],
    },
    {
      name: 'code',
      type: 'string',
      description: 'Code to execute',
      required: true,
    },
  ],
  execute: async (args: Record<string, any>): Promise<ToolResult> => {
    try {
      const language = String(args.language || '').toLowerCase().trim();
      const code = String(args.code || '').trim();

      if (!language) {
        return {
          success: false,
          content: JSON.stringify({ error: 'Language is required' }),
        };
      }

      if (!code) {
        return {
          success: false,
          content: JSON.stringify({ error: 'Code is required' }),
        };
      }

      if (language !== 'python' && language !== 'javascript' && language !== 'py' && language !== 'js') {
        return {
          success: false,
          content: JSON.stringify({ error: 'Language must be "python" or "javascript"' }),
        };
      }

      const startTime = Date.now();
      const output = await executeCode(language, code);
      const durationMs = Date.now() - startTime;

      return {
        success: true,
        content: JSON.stringify({
          language,
          output: output || '(no output)',
          truncated: output.length >= MAX_OUTPUT_LENGTH,
        }),
        metadata: {
          duration_ms: durationMs,
        },
      };
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      return {
        success: false,
        content: JSON.stringify({
          error: `Code execution failed: ${errorMessage}`,
        }),
      };
    }
  },
};

fs-advanced-ops-tool.ts

Path: starbot_api/src/services/tools/fs-advanced-ops-tool.ts

Size: 8628 bytes

import type { ToolDefinition, ToolResult } from './types.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const TOOL_NAME = 'fs_advanced_ops';

const description = `Perform advanced file operations: create directories, move/rename files, delete files and directories. Use for project structure management.`;

// Block dangerous operations
const BLOCKED_PATHS = ['/etc', '/usr', '/bin', '/sbin', '/var', '/sys', '/proc'];

interface FsAdvancedOpsArgs {
  operation: 'mkdir' | 'mv' | 'rm' | 'cp';
  source: string;
  destination?: string;
  recursive?: boolean;
  force?: boolean;
}

function validatePath(filePath: string, workspaceRoot: string): string | null {
  const cleaned = filePath.trim();
  if (path.isAbsolute(cleaned)) {
    // Allow absolute paths within workspace
    if (!cleaned.startsWith(workspaceRoot)) {
      return null;
    }
    return cleaned;
  }
  const normalized = path.normalize(cleaned);
  if (normalized.includes('..')) {
    return null;
  }
  const fullPath = path.resolve(workspaceRoot, normalized);
  const resolvedWorkspace = path.resolve(workspaceRoot);
  if (!fullPath.startsWith(resolvedWorkspace)) {
    return null;
  }
  return fullPath;
}

function isPathBlocked(fullPath: string): boolean {
  const lower = fullPath.toLowerCase();
  return BLOCKED_PATHS.some(blocked => lower.startsWith(blocked));
}

export const fsAdvancedOpsTool: ToolDefinition = {
  name: TOOL_NAME,
  description,
  parameters: [
    {
      name: 'operation',
      type: 'string',
      description: 'Operation to perform: mkdir (create directory), mv (move/rename), rm (delete), cp (copy)',
      required: true,
      enum: ['mkdir', 'mv', 'rm', 'cp'],
    },
    {
      name: 'source',
      type: 'string',
      description: 'Source path (file or directory)',
      required: true,
    },
    {
      name: 'destination',
      type: 'string',
      description: 'Destination path (for mv and cp operations)',
      required: false,
    },
    {
      name: 'recursive',
      type: 'boolean',
      description: 'For rm: delete directories recursively. For mkdir: create parent directories (default: true)',
      required: false,
      default: true,
    },
    {
      name: 'force',
      type: 'boolean',
      description: 'For rm: force deletion without confirmation. Ignore errors for missing files (default: false)',
      required: false,
      default: false,
    },
  ],

  async execute(args: Record<string, any>): Promise<ToolResult> {
    const { operation, source, destination, recursive = true, force = false } = args as FsAdvancedOpsArgs;

    const workspaceRoot = process.env.WORKSPACE_ROOT || process.cwd();

    // Validate source path
    const sourcePath = validatePath(source, workspaceRoot);
    if (!sourcePath) {
      return {
        success: false,
        content: JSON.stringify({
          error: 'Invalid source path. Path traversal attempts are not allowed.',
        }),
      };
    }

    // Security check
    if (isPathBlocked(sourcePath)) {
      return {
        success: false,
        content: JSON.stringify({
          error: 'Cannot operate on system directories.',
        }),
      };
    }

    try {
      switch (operation) {
        case 'mkdir': {
          await fs.mkdir(sourcePath, { recursive });
          return {
            success: true,
            content: JSON.stringify({
              message: `Directory created: ${source}`,
              path: source,
              operation: 'mkdir',
            }),
          };
        }

        case 'mv': {
          if (!destination) {
            return {
              success: false,
              content: JSON.stringify({
                error: 'Destination is required for mv operation',
              }),
            };
          }

          const destPath = validatePath(destination, workspaceRoot);
          if (!destPath) {
            return {
              success: false,
              content: JSON.stringify({
                error: 'Invalid destination path. Path traversal attempts are not allowed.',
              }),
            };
          }

          // Check if source exists
          try {
            await fs.access(sourcePath);
          } catch {
            return {
              success: false,
              content: JSON.stringify({
                error: `Source does not exist: ${source}`,
              }),
            };
          }

          await fs.rename(sourcePath, destPath);
          return {
            success: true,
            content: JSON.stringify({
              message: `Moved/renamed: ${source} -> ${destination}`,
              source,
              destination,
              operation: 'mv',
            }),
          };
        }

        case 'rm': {
          // Check if path exists
          try {
            await fs.access(sourcePath);
          } catch {
            if (force) {
              return {
                success: true,
                content: JSON.stringify({
                  message: `Path does not exist (ignored): ${source}`,
                  path: source,
                  operation: 'rm',
                }),
              };
            }
            return {
              success: false,
              content: JSON.stringify({
                error: `Path does not exist: ${source}`,
              }),
            };
          }

          const stats = await fs.stat(sourcePath);

          if (stats.isDirectory()) {
            if (recursive) {
              await fs.rm(sourcePath, { recursive: true, force });
            } else {
              return {
                success: false,
                content: JSON.stringify({
                  error: `Cannot delete directory without recursive flag: ${source}`,
                }),
              };
            }
          } else {
            await fs.unlink(sourcePath);
          }

          return {
            success: true,
            content: JSON.stringify({
              message: `Deleted: ${source}`,
              path: source,
              operation: 'rm',
              is_directory: stats.isDirectory(),
            }),
          };
        }

        case 'cp': {
          if (!destination) {
            return {
              success: false,
              content: JSON.stringify({
                error: 'Destination is required for cp operation',
              }),
            };
          }

          const destPath = validatePath(destination, workspaceRoot);
          if (!destPath) {
            return {
              success: false,
              content: JSON.stringify({
                error: 'Invalid destination path. Path traversal attempts are not allowed.',
              }),
            };
          }

          // Check if source exists
          try {
            await fs.access(sourcePath);
          } catch {
            return {
              success: false,
              content: JSON.stringify({
                error: `Source does not exist: ${source}`,
              }),
            };
          }

          const stats = await fs.stat(sourcePath);

          if (stats.isDirectory()) {
            // Recursive copy directory
            await copyDirectory(sourcePath, destPath);
          } else {
            await fs.copyFile(sourcePath, destPath);
          }

          return {
            success: true,
            content: JSON.stringify({
              message: `Copied: ${source} -> ${destination}`,
              source,
              destination,
              operation: 'cp',
            }),
          };
        }

        default:
          return {
            success: false,
            content: JSON.stringify({
              error: `Unknown operation: ${operation}`,
            }),
          };
      }
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      return {
        success: false,
        content: JSON.stringify({
          error: `Operation failed: ${errorMessage}`,
          operation,
          source,
          destination,
        }),
      };
    }
  },
};

async function copyDirectory(src: string, dest: string): Promise<void> {
  await fs.mkdir(dest, { recursive: true });
  const entries = await fs.readdir(src, { withFileTypes: true });

  for (const entry of entries) {
    const srcPath = path.join(src, entry.name);
    const destPath = path.join(dest, entry.name);

    if (entry.isDirectory()) {
      await copyDirectory(srcPath, destPath);
    } else {
      await fs.copyFile(srcPath, destPath);
    }
  }
}

export default fsAdvancedOpsTool;

fs-edit-file-tool.ts

Path: starbot_api/src/services/tools/fs-edit-file-tool.ts

Size: 5781 bytes

import type { ToolDefinition, ToolResult } from './types.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const TOOL_NAME = 'fs_edit_file';

const description = `Edits an existing file by replacing specific text with new content. This is safer than overwriting entire files as it preserves the surrounding context. Use this to make targeted changes to code, configuration, or any text file.`;

interface EditFileArgs {
  path: string;
  search: string;
  replace: string;
  global?: boolean;
  create_backup?: boolean;
}

function validatePath(filePath: string, workspaceRoot: string): string | null {
  const cleaned = filePath.trim();

  if (path.isAbsolute(cleaned)) {
    return null;
  }

  const normalized = path.normalize(cleaned);
  if (normalized.includes('..')) {
    return null;
  }

  const fullPath = path.resolve(workspaceRoot, normalized);
  const resolvedWorkspace = path.resolve(workspaceRoot);

  if (!fullPath.startsWith(resolvedWorkspace)) {
    return null;
  }

  return fullPath;
}

export const fsEditFileTool: ToolDefinition = {
  name: TOOL_NAME,
  description,
  parameters: [
    {
      name: 'path',
      type: 'string',
      description: 'The file path to edit (relative to workspace, e.g., "src/app.ts")',
      required: true,
    },
    {
      name: 'search',
      type: 'string',
      description: 'The exact text to search for in the file (must match exactly)',
      required: true,
    },
    {
      name: 'replace',
      type: 'string',
      description: 'The new text to replace the search text with',
      required: true,
    },
    {
      name: 'global',
      type: 'boolean',
      description: 'If true, replace all occurrences. If false, replace only the first occurrence (default: false)',
      required: false,
      default: false,
    },
    {
      name: 'create_backup',
      type: 'boolean',
      description: 'Whether to create a backup before editing (default: true)',
      required: false,
      default: true,
    },
  ],

  async execute(args: Record<string, any>): Promise<ToolResult> {
    const { path: filePath, search, replace, global = false, create_backup = true } = args as EditFileArgs;

    const workspaceRoot = process.env.WORKSPACE_ROOT || process.cwd();
    const fullPath = validatePath(filePath, workspaceRoot);

    if (!fullPath) {
      return {
        content: JSON.stringify({
          success: false,
          error: 'Invalid file path. Path traversal attempts or absolute paths outside workspace are not allowed.',
        }),
        success: false,
      };
    }

    try {
      // Read existing file content
      let originalContent: string;
      try {
        originalContent = await fs.readFile(fullPath, 'utf-8');
      } catch (error) {
        return {
          content: JSON.stringify({
            success: false,
            error: `File not found: "${filePath}". Use fs_write_file to create new files.`,
          }),
          success: false,
        };
      }

      // Create backup if requested
      let backupPath: string | undefined;
      if (create_backup) {
        const backupDir = path.join(workspaceRoot, '.backups');
        await fs.mkdir(backupDir, { recursive: true });

        const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
        const backupFileName = `${path.basename(filePath)}.${timestamp}.bak`;
        backupPath = path.join(backupDir, backupFileName);

        await fs.writeFile(backupPath, originalContent, 'utf-8');
      }

      // Perform the replacement
      let newContent: string;
      let matchCount: number;

      if (global) {
        // Replace all occurrences
        if (!originalContent.includes(search)) {
          return {
            content: JSON.stringify({
              success: false,
              error: `Search text not found in file. Make sure the search text matches exactly, including whitespace and newlines.`,
            }),
            success: false,
          };
        }
        matchCount = (originalContent.match(new RegExp(escapeRegExp(search), 'g')) || []).length;
        newContent = originalContent.split(search).join(replace);
      } else {
        // Replace first occurrence only
        const index = originalContent.indexOf(search);
        if (index === -1) {
          return {
            content: JSON.stringify({
              success: false,
              error: `Search text not found in file. Make sure the search text matches exactly, including whitespace and newlines.`,
            }),
            success: false,
          };
        }
        matchCount = 1;
        newContent = originalContent.slice(0, index) + replace + originalContent.slice(index + search.length);
      }

      // Write the modified content
      await fs.writeFile(fullPath, newContent, 'utf-8');

      return {
        content: JSON.stringify({
          success: true,
          message: `File edited successfully. ${matchCount} occurrence(s) replaced.`,
          path: filePath,
          fullPath: fullPath,
          replacements: matchCount,
          backup_path: backupPath,
          diff: {
            old_length: originalContent.length,
            new_length: newContent.length,
            change: newContent.length - originalContent.length,
          },
        }),
        success: true,
      };
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      return {
        content: JSON.stringify({
          success: false,
          error: `Failed to edit file: ${errorMessage}`,
        }),
        success: false,
      };
    }
  },
};

function escapeRegExp(string: string): string {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

export default fsEditFileTool;

fs-write-file-tool.ts

Path: starbot_api/src/services/tools/fs-write-file-tool.ts

Size: 4155 bytes

import type { ToolDefinition, ToolResult } from './types.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const TOOL_NAME = 'fs_write_file';

const description = `Writes content to a file at a specified path. Creates the file if it doesn't exist, or overwrites it if it does. Automatically creates parent directories as needed. Use this tool to save code, configuration, text, or any content to the filesystem.`;

interface WriteFileArgs {
  path: string;
  content: string;
  create_backup?: boolean;
}

function validatePath(filePath: string, workspaceRoot: string): string | null {
  // Remove leading/trailing whitespace
  const cleaned = filePath.trim();

  // Reject absolute paths that escape workspace
  if (path.isAbsolute(cleaned)) {
    return null;
  }

  // Reject path traversal attempts
  const normalized = path.normalize(cleaned);
  if (normalized.includes('..')) {
    return null;
  }

  // Resolve to absolute path within workspace
  const fullPath = path.resolve(workspaceRoot, normalized);

  // Ensure the resolved path is still within workspace
  const resolvedWorkspace = path.resolve(workspaceRoot);
  if (!fullPath.startsWith(resolvedWorkspace)) {
    return null;
  }

  return fullPath;
}

export const fsWriteFileTool: ToolDefinition = {
  name: TOOL_NAME,
  description,
  parameters: [
    {
      name: 'path',
      type: 'string',
      description: 'The file path to write to (relative to workspace, e.g., "src/app.ts" or "docs/readme.md")',
      required: true,
    },
    {
      name: 'content',
      type: 'string',
      description: 'The complete content to write to the file',
      required: true,
    },
    {
      name: 'create_backup',
      type: 'boolean',
      description: 'Whether to create a backup before overwriting (default: false)',
      required: false,
      default: false,
    },
  ],

  async execute(args: Record<string, any>): Promise<ToolResult> {
    const { path: filePath, content, create_backup = false } = args as WriteFileArgs;

    // Get workspace root - use environment or default to process cwd
    const workspaceRoot = process.env.WORKSPACE_ROOT || process.cwd();

    // Validate path
    const fullPath = validatePath(filePath, workspaceRoot);
    if (!fullPath) {
      return {
        content: JSON.stringify({
          success: false,
          error: 'Invalid file path. Path traversal attempts or absolute paths outside workspace are not allowed.',
        }),
        success: false,
      };
    }

    try {
      // Create backup if requested and file exists
      let backupPath: string | undefined;
      if (create_backup) {
        try {
          await fs.access(fullPath);
          const backupDir = path.join(workspaceRoot, '.backups');
          await fs.mkdir(backupDir, { recursive: true });

          const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
          const backupFileName = `${path.basename(filePath)}.${timestamp}.bak`;
          backupPath = path.join(backupDir, backupFileName);

          await fs.copyFile(fullPath, backupPath);
        } catch {
          // File doesn't exist, no backup needed
        }
      }

      // Ensure parent directory exists
      const parentDir = path.dirname(fullPath);
      await fs.mkdir(parentDir, { recursive: true });

      // Write file
      await fs.writeFile(fullPath, content, 'utf-8');

      // Get file stats for response
      const stats = await fs.stat(fullPath);

      return {
        content: JSON.stringify({
          success: true,
          message: `File written successfully to "${filePath}"`,
          path: filePath,
          fullPath: fullPath,
          size: stats.size,
          lines: content.split('\n').length,
          backup_path: backupPath,
        }),
        success: true,
      };
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      return {
        content: JSON.stringify({
          success: false,
          error: `Failed to write file: ${errorMessage}`,
        }),
        success: false,
      };
    }
  },
};

export default fsWriteFileTool;

web-search-tool.ts

Path: starbot_api/src/services/tools/web-search-tool.ts

Size: 1981 bytes

// Web Search Tool
// Wraps the existing web search service as a tool

import type { ToolDefinition, ToolResult } from './types.js';
import { searchWeb, formatWebSearchContext } from '../web-search.js';

export const webSearchTool: ToolDefinition = {
  name: 'web_search',
  description: 'Search the web for current information and recent events. Use this when you need up-to-date information not in your training data.',
  parameters: [
    {
      name: 'query',
      type: 'string',
      description: 'The search query to look up on the web',
      required: true,
    },
    {
      name: 'num_results',
      type: 'number',
      description: 'Number of results to return (1-10, default: 5)',
      required: false,
      default: 5,
    },
  ],
  execute: async (args: Record<string, any>): Promise<ToolResult> => {
    try {
      const query = String(args.query || '').trim();
      if (!query) {
        return {
          success: false,
          content: JSON.stringify({ error: 'Query is required' }),
        };
      }

      const numResults = Math.max(1, Math.min(10, parseInt(String(args.num_results || '5'), 10)));
      const result = await searchWeb(query, numResults);

      if (!result) {
        return {
          success: false,
          content: JSON.stringify({ error: 'Web search is not enabled or API key is missing' }),
        };
      }

      // Format results as structured data
      const formatted = {
        query: result.query,
        results: result.hits.map((hit, idx) => ({
          rank: idx + 1,
          title: hit.title,
          url: hit.url,
          snippet: hit.snippet,
        })),
      };

      return {
        success: true,
        content: JSON.stringify(formatted, null, 2),
      };
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      return {
        success: false,
        content: JSON.stringify({ error: errorMessage }),
      };
    }
  },
};

types.ts

Path: starbot_api/src/services/tools/types.ts

Size: 785 bytes

// Tool system types and interfaces
// Defines the schema and interfaces for tools in Starbot

export interface ToolParameter {
  name: string;
  type: 'string' | 'number' | 'boolean' | 'array' | 'object';
  description: string;
  required: boolean;
  enum?: string[]; // For enum types
  default?: any;
}

export interface ToolDefinition {
  name: string;
  description: string;
  parameters: ToolParameter[];
  execute: (args: Record<string, any>) => Promise<ToolResult>;
}

export interface ToolResult {
  success: boolean;
  content: string; // JSON stringified result or error message
  metadata?: {
    duration_ms?: number;
    tokens_used?: number;
    [key: string]: any;
  };
}

export interface ToolCall {
  id: string;
  name: string;
  arguments: string; // JSON string
}

index.ts

Path: starbot_api/src/services/tools/index.ts

Size: 3407 bytes

// Tool System Initialization
// Registers all available tools on startup

import { env } from '../../env.js';
import { toolRegistry } from './registry.js';
import { webSearchTool } from './web-search-tool.js';
import { calculatorTool } from './calculator-tool.js';
import { codeExecTool } from './code-exec-tool.js';
import { fileReadTool } from './file-read-tool.js';
import { fsWriteFileTool } from './fs-write-file-tool.js';
import { fsEditFileTool } from './fs-edit-file-tool.js';
import { fsGlobTool } from './fs-glob-tool.js';
import { fsGrepTool } from './fs-grep-tool.js';
import { shellExecTool } from './shell-exec-tool.js';
import { fsAdvancedOpsTool } from './fs-advanced-ops-tool.js';

export { toolRegistry } from './registry.js';
export type { ToolDefinition, ToolResult, ToolCall, ToolParameter } from './types.js';

/**
 * Returns only the tools matching the given names.
 * Used by the Codex router to selectively inject tools per-request.
 */
export function getToolsByNames(names: string[]): import('./types.js').ToolDefinition[] {
  if (!names || names.length === 0) return [];
  const nameSet = new Set(names.map(n => n.toLowerCase()));
  return toolRegistry.getAll().filter(t => nameSet.has(t.name.toLowerCase()));
}

export function initializeTools(): void {
  console.log('Initializing tool system...');

  // Register web search tool if enabled
  if (env.TOOLS_ENABLED && env.WEB_SEARCH_ENABLED && env.BRAVE_SEARCH_API_KEY) {
    toolRegistry.register(webSearchTool);
    console.log('✓ Web search tool registered');
  }

  // Register calculator tool
  if (env.TOOLS_ENABLED) {
    toolRegistry.register(calculatorTool);
    console.log('✓ Calculator tool registered');
  }

  // Register code execution tool (disabled by default for security)
  if (env.TOOLS_ENABLED && env.CODE_EXECUTION_ENABLED) {
    toolRegistry.register(codeExecTool);
    console.log('✓ Code execution tool registered (SECURITY WARNING: Code execution enabled)');
  }

  // Register file read tool
  if (env.TOOLS_ENABLED) {
    toolRegistry.register(fileReadTool);
    console.log('✓ File read tool registered');
  }

  // Register file write tool
  if (env.TOOLS_ENABLED) {
    toolRegistry.register(fsWriteFileTool);
    console.log('✓ File write tool registered');
  }

  // Register file edit tool (search and replace)
  if (env.TOOLS_ENABLED) {
    toolRegistry.register(fsEditFileTool);
    console.log('✓ File edit tool registered');
  }

  // Register glob tool (find files by pattern)
  if (env.TOOLS_ENABLED) {
    toolRegistry.register(fsGlobTool);
    console.log('✓ Glob tool registered');
  }

  // Register grep tool (search content in files)
  if (env.TOOLS_ENABLED) {
    toolRegistry.register(fsGrepTool);
    console.log('✓ Grep tool registered');
  }

  // Register shell execution tool
  if (env.TOOLS_ENABLED && env.SHELL_EXEC_ENABLED) {
    toolRegistry.register(shellExecTool);
    console.log('✓ Shell execution tool registered');
  }

  // Register advanced file operations tool
  if (env.TOOLS_ENABLED) {
    toolRegistry.register(fsAdvancedOpsTool);
    console.log('✓ Advanced file operations tool registered');
  }

  const registeredTools = toolRegistry.getAll();
  console.log(`Tool system initialized with ${registeredTools.length} tool(s)`);

  if (registeredTools.length > 0) {
    console.log(`Registered tools: ${registeredTools.map(t => t.name).join(', ')}`);
  }
}

sandbox-exec-tool.ts

Path: starbot_api/src/services/tools/sandbox-exec-tool.ts

Size: 11795 bytes

/**
 * Enhanced Sandboxed Code Execution Tool
 *
 * Features:
 * - Multiple language support (Python, JS, TS, Bash, Ruby, Go)
 * - Resource limits (CPU, memory, file descriptors)
 * - Workspace file access (read-only by default)
 * - Network isolation option
 * - Streaming output support
 * - Package/import support for common libraries
 */

import type { ToolDefinition, ToolResult } from './types.js';
import { spawn, SpawnOptions } from 'child_process';
import { writeFile, readFile, unlink, mkdir, rm, copyFile, readdir } from 'fs/promises';
import { tmpdir } from 'os';
import { join, basename, dirname } from 'path';
import { randomBytes } from 'crypto';

// Configuration
const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds
const MAX_TIMEOUT_MS = 120_000; // 2 minutes max
const MAX_OUTPUT_LENGTH = 50_000; // 50KB output
const MAX_MEMORY_MB = 512; // 512MB memory limit
const SANDBOX_DIR_PREFIX = 'starbot-sandbox-';

interface ExecutionConfig {
  language: string;
  code: string;
  timeout_ms?: number;
  workspace_files?: string[]; // Files to copy into sandbox (read-only)
  allow_network?: boolean;
  env_vars?: Record<string, string>;
  stdin?: string;
  args?: string[];
}

interface LanguageRuntime {
  extension: string;
  command: string;
  args: (filename: string, userArgs?: string[]) => string[];
  setup?: (sandboxDir: string, code: string) => Promise<void>;
  prelude?: string;
}

const RUNTIMES: Record<string, LanguageRuntime> = {
  python: {
    extension: '.py',
    command: 'python3',
    args: (f, userArgs) => ['-u', f, ...(userArgs || [])], // -u for unbuffered output
    prelude: `
import sys
sys.path.insert(0, '.')
`,
  },
  py: {
    extension: '.py',
    command: 'python3',
    args: (f, userArgs) => ['-u', f, ...(userArgs || [])],
    prelude: `
import sys
sys.path.insert(0, '.')
`,
  },
  javascript: {
    extension: '.js',
    command: 'node',
    args: (f, userArgs) => ['--no-warnings', f, ...(userArgs || [])],
  },
  js: {
    extension: '.js',
    command: 'node',
    args: (f, userArgs) => ['--no-warnings', f, ...(userArgs || [])],
  },
  typescript: {
    extension: '.ts',
    command: 'npx',
    args: (f, userArgs) => ['tsx', f, ...(userArgs || [])],
  },
  ts: {
    extension: '.ts',
    command: 'npx',
    args: (f, userArgs) => ['tsx', f, ...(userArgs || [])],
  },
  bash: {
    extension: '.sh',
    command: 'bash',
    args: (f, userArgs) => [f, ...(userArgs || [])],
    prelude: 'set -e\n', // Exit on error
  },
  sh: {
    extension: '.sh',
    command: 'sh',
    args: (f, userArgs) => [f, ...(userArgs || [])],
  },
  ruby: {
    extension: '.rb',
    command: 'ruby',
    args: (f, userArgs) => [f, ...(userArgs || [])],
  },
  rb: {
    extension: '.rb',
    command: 'ruby',
    args: (f, userArgs) => [f, ...(userArgs || [])],
  },
  go: {
    extension: '.go',
    command: 'go',
    args: (f, userArgs) => ['run', f, ...(userArgs || [])],
  },
};

async function createSandbox(): Promise<string> {
  const sandboxId = randomBytes(8).toString('hex');
  const sandboxDir = join(tmpdir(), `${SANDBOX_DIR_PREFIX}${sandboxId}`);
  await mkdir(sandboxDir, { recursive: true });
  return sandboxDir;
}

async function cleanupSandbox(sandboxDir: string): Promise<void> {
  try {
    await rm(sandboxDir, { recursive: true, force: true });
  } catch {
    // Ignore cleanup errors
  }
}

async function copyWorkspaceFiles(
  sandboxDir: string,
  files: string[],
  workspaceRoot: string
): Promise<string[]> {
  const copiedFiles: string[] = [];

  for (const file of files) {
    try {
      const sourcePath = join(workspaceRoot, file);
      const destPath = join(sandboxDir, basename(file));

      // Ensure we don't escape workspace
      if (!sourcePath.startsWith(workspaceRoot)) continue;

      await copyFile(sourcePath, destPath);
      copiedFiles.push(basename(file));
    } catch {
      // Skip files that can't be copied
    }
  }

  return copiedFiles;
}

interface ExecutionResult {
  stdout: string;
  stderr: string;
  exitCode: number | null;
  signal: string | null;
  timedOut: boolean;
  durationMs: number;
}

async function executeInSandbox(
  sandboxDir: string,
  runtime: LanguageRuntime,
  filename: string,
  config: ExecutionConfig
): Promise<ExecutionResult> {
  const timeout = Math.min(config.timeout_ms || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);

  const env: Record<string, string> = {
    ...process.env,
    HOME: sandboxDir,
    TMPDIR: sandboxDir,
    PATH: process.env.PATH || '/usr/bin:/bin',
    ...config.env_vars,
  };

  // Network isolation (Linux only - uses unshare if available)
  let command = runtime.command;
  let args = runtime.args(filename, config.args);

  // Add resource limits via ulimit wrapper on Linux
  if (process.platform === 'linux' && !config.allow_network) {
    // Wrap with timeout and resource limits
    const limits = [
      `ulimit -v ${MAX_MEMORY_MB * 1024}`, // Virtual memory limit
      `ulimit -t ${Math.ceil(timeout / 1000)}`, // CPU time limit
      `ulimit -f 10240`, // File size limit (10MB)
    ].join(' && ');

    args = ['-c', `${limits} && ${command} ${args.map(a => `'${a}'`).join(' ')}`];
    command = 'bash';
  }

  const spawnOptions: SpawnOptions = {
    cwd: sandboxDir,
    env,
    stdio: ['pipe', 'pipe', 'pipe'],
    timeout,
  };

  return new Promise((resolve) => {
    const startTime = Date.now();
    let stdout = '';
    let stderr = '';
    let timedOut = false;

    const proc = spawn(command, args, spawnOptions);

    // Send stdin if provided
    if (config.stdin && proc.stdin) {
      proc.stdin.write(config.stdin);
      proc.stdin.end();
    }

    proc.stdout?.on('data', (data) => {
      stdout += data.toString();
      if (stdout.length > MAX_OUTPUT_LENGTH) {
        stdout = stdout.slice(0, MAX_OUTPUT_LENGTH) + '\n...[output truncated]';
        proc.kill('SIGTERM');
      }
    });

    proc.stderr?.on('data', (data) => {
      stderr += data.toString();
      if (stderr.length > MAX_OUTPUT_LENGTH) {
        stderr = stderr.slice(0, MAX_OUTPUT_LENGTH) + '\n...[output truncated]';
      }
    });

    const timer = setTimeout(() => {
      timedOut = true;
      proc.kill('SIGKILL');
    }, timeout);

    proc.on('close', (code, signal) => {
      clearTimeout(timer);
      resolve({
        stdout,
        stderr,
        exitCode: code,
        signal: signal as string | null,
        timedOut,
        durationMs: Date.now() - startTime,
      });
    });

    proc.on('error', (err) => {
      clearTimeout(timer);
      resolve({
        stdout,
        stderr: stderr + '\n' + err.message,
        exitCode: -1,
        signal: null,
        timedOut: false,
        durationMs: Date.now() - startTime,
      });
    });
  });
}

export const sandboxExecTool: ToolDefinition = {
  name: 'sandbox_execute',
  description: `Execute code in a secure sandboxed environment with resource limits.

Supported languages: python, javascript, typescript, bash, ruby, go

Features:
- 30 second default timeout (max 2 minutes)
- Memory limit: 512MB
- Can access workspace files (read-only)
- Isolated temporary directory
- Captures stdout, stderr, and exit code

Use for:
- Running calculations and data processing
- Testing code snippets
- Executing scripts with file I/O
- Quick prototyping`,

  parameters: [
    {
      name: 'language',
      type: 'string',
      description: 'Programming language (python, javascript, typescript, bash, ruby, go)',
      required: true,
      enum: ['python', 'javascript', 'typescript', 'bash', 'ruby', 'go', 'py', 'js', 'ts', 'sh', 'rb'],
    },
    {
      name: 'code',
      type: 'string',
      description: 'Code to execute',
      required: true,
    },
    {
      name: 'timeout_ms',
      type: 'number',
      description: 'Execution timeout in milliseconds (default: 30000, max: 120000)',
      required: false,
      default: 30000,
    },
    {
      name: 'workspace_files',
      type: 'array',
      description: 'List of workspace files to copy into sandbox (read-only access)',
      required: false,
    },
    {
      name: 'stdin',
      type: 'string',
      description: 'Input to send to stdin',
      required: false,
    },
    {
      name: 'args',
      type: 'array',
      description: 'Command line arguments to pass to the script',
      required: false,
    },
    {
      name: 'env_vars',
      type: 'object',
      description: 'Environment variables to set',
      required: false,
    },
  ],

  async execute(args: Record<string, any>): Promise<ToolResult> {
    const config = args as ExecutionConfig;

    // Validate language
    const lang = config.language?.toLowerCase().trim();
    const runtime = RUNTIMES[lang];

    if (!runtime) {
      const supported = Object.keys(RUNTIMES).filter(k => !['py', 'js', 'ts', 'sh', 'rb'].includes(k));
      return {
        success: false,
        content: JSON.stringify({
          error: `Unsupported language: ${lang}`,
          supported_languages: supported,
        }),
      };
    }

    if (!config.code?.trim()) {
      return {
        success: false,
        content: JSON.stringify({ error: 'Code is required' }),
      };
    }

    let sandboxDir: string | null = null;

    try {
      // Create isolated sandbox directory
      sandboxDir = await createSandbox();

      // Copy workspace files if requested
      const workspaceRoot = process.env.WORKSPACE_ROOT || process.cwd();
      let copiedFiles: string[] = [];
      if (config.workspace_files?.length) {
        copiedFiles = await copyWorkspaceFiles(sandboxDir, config.workspace_files, workspaceRoot);
      }

      // Write code to file
      const filename = `main${runtime.extension}`;
      const filepath = join(sandboxDir, filename);

      const fullCode = (runtime.prelude || '') + config.code;
      await writeFile(filepath, fullCode, 'utf-8');

      // Run setup if defined
      if (runtime.setup) {
        await runtime.setup(sandboxDir, config.code);
      }

      // Execute
      const result = await executeInSandbox(sandboxDir, runtime, filename, config);

      // Format result
      const output: Record<string, any> = {
        success: result.exitCode === 0,
        language: lang,
        exit_code: result.exitCode,
        duration_ms: result.durationMs,
      };

      if (result.stdout) {
        output.stdout = result.stdout;
      }

      if (result.stderr) {
        output.stderr = result.stderr;
      }

      if (result.timedOut) {
        output.error = `Execution timed out after ${config.timeout_ms || DEFAULT_TIMEOUT_MS}ms`;
        output.timed_out = true;
      }

      if (result.signal) {
        output.signal = result.signal;
      }

      if (copiedFiles.length > 0) {
        output.workspace_files_copied = copiedFiles;
      }

      // Check for generated files in sandbox
      try {
        const sandboxFiles = await readdir(sandboxDir);
        const generatedFiles = sandboxFiles.filter(f => f !== filename && !copiedFiles.includes(f));
        if (generatedFiles.length > 0) {
          output.generated_files = generatedFiles;
        }
      } catch {
        // Ignore
      }

      return {
        success: result.exitCode === 0 && !result.timedOut,
        content: JSON.stringify(output, null, 2),
        metadata: {
          duration_ms: result.durationMs,
          sandbox_id: basename(sandboxDir),
        },
      };
    } catch (error) {
      return {
        success: false,
        content: JSON.stringify({
          error: `Sandbox execution failed: ${error instanceof Error ? error.message : String(error)}`,
        }),
      };
    } finally {
      // Always cleanup sandbox
      if (sandboxDir) {
        await cleanupSandbox(sandboxDir);
      }
    }
  },
};

export default sandboxExecTool;

registry.test.ts

Path: starbot_api/src/services/tools/__tests__/registry.test.ts

Size: 3362 bytes

import { describe, it, expect } from 'vitest';
import { ToolRegistry } from '../registry.js';
import type { ToolDefinition } from '../types.js';

describe('Tool Registry', () => {
  it('should register and retrieve tools', () => {
    const registry = new ToolRegistry();

    const testTool: ToolDefinition = {
      name: 'test_tool',
      description: 'A test tool',
      parameters: [
        {
          name: 'input',
          type: 'string',
          description: 'Test input',
          required: true,
        },
      ],
      execute: async () => ({
        success: true,
        content: 'test result',
      }),
    };

    registry.register(testTool);
    expect(registry.has('test_tool')).toBe(true);
    expect(registry.get('test_tool')).toEqual(testTool);
  });

  it('should list all registered tools', () => {
    const registry = new ToolRegistry();

    const tool1: ToolDefinition = {
      name: 'tool1',
      description: 'Tool 1',
      parameters: [],
      execute: async () => ({ success: true, content: '' }),
    };

    const tool2: ToolDefinition = {
      name: 'tool2',
      description: 'Tool 2',
      parameters: [],
      execute: async () => ({ success: true, content: '' }),
    };

    registry.register(tool1);
    registry.register(tool2);

    const allTools = registry.getAll();
    expect(allTools.length).toBe(2);
    expect(allTools.map(t => t.name)).toContain('tool1');
    expect(allTools.map(t => t.name)).toContain('tool2');
  });

  it('should convert tools to OpenAI function format', () => {
    const registry = new ToolRegistry();

    const testTool: ToolDefinition = {
      name: 'test_tool',
      description: 'A test tool',
      parameters: [
        {
          name: 'query',
          type: 'string',
          description: 'Search query',
          required: true,
        },
        {
          name: 'limit',
          type: 'number',
          description: 'Result limit',
          required: false,
          default: 10,
        },
      ],
      execute: async () => ({ success: true, content: '' }),
    };

    registry.register(testTool);

    const openAIFunctions = registry.toOpenAIFunctions();
    expect(openAIFunctions.length).toBe(1);

    const fn = openAIFunctions[0];
    expect(fn.name).toBe('test_tool');
    expect(fn.description).toBe('A test tool');
    expect(fn.parameters.type).toBe('object');
    expect(fn.parameters.required).toContain('query');
    expect(fn.parameters.required).not.toContain('limit');
    expect(fn.parameters.properties.query.type).toBe('string');
    expect(fn.parameters.properties.limit.type).toBe('number');
    expect(fn.parameters.properties.limit.default).toBe(10);
  });

  it('should handle tool overwriting', () => {
    const registry = new ToolRegistry();

    const tool1: ToolDefinition = {
      name: 'tool',
      description: 'Version 1',
      parameters: [],
      execute: async () => ({ success: true, content: 'v1' }),
    };

    const tool2: ToolDefinition = {
      name: 'tool',
      description: 'Version 2',
      parameters: [],
      execute: async () => ({ success: true, content: 'v2' }),
    };

    registry.register(tool1);
    registry.register(tool2);

    expect(registry.getAll().length).toBe(1);
    const tool = registry.get('tool');
    expect(tool?.description).toBe('Version 2');
  });
});

parser.ts

Path: starbot_api/src/services/orchestrator/parser.ts

Size: 5212 bytes

// DeepSeek Output Parser
// Parses DeepSeek R1's response to extract tool call requests
// DeepSeek outputs tool requests in JSON format inside markdown code blocks

import type { ToolCallRequest } from './types.js';

const TOOL_NAMES = [
  'read_file',
  'file_read',
  'write_file',
  'file_write',
  'edit_file',
  'file_edit',
  'list_directory',
  'glob',
  'grep',
  'calculator',
  'web_search',
  'search',
  'bash',
  'shell',
  'run_command',
];

export function parseToolCallsFromResponse(response: string): ToolCallRequest[] {
  const toolCalls: ToolCallRequest[] = [];

  console.log('[Parser] Received response, length:', response.length);
  console.log('[Parser] First 200 chars:', response.slice(0, 200));

  // First, try to extract XML-like tool calls (DeepSeek often uses this format)
  const xmlToolCalls = parseXmlToolCalls(response);
  console.log('[Parser] XML tool calls found:', xmlToolCalls.length);
  if (xmlToolCalls.length > 0) {
    console.log('[Parser] XML tool calls:', JSON.stringify(xmlToolCalls));
    return xmlToolCalls;
  }

  // Try to extract JSON from response
  // Remove thinking tags but preserve JSON structure
  let cleanedResponse = response
    .replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
    .trim();

  // Try to find JSON blocks in markdown code blocks first
  const codeBlockMatches = cleanedResponse.match(/```json\s*(\{[\s\S]*?\})\s*```/g);
  if (codeBlockMatches) {
    console.log('[Parser] Found code blocks:', codeBlockMatches.length);
    for (const block of codeBlockMatches) {
      const jsonMatch = block.match(/\{[\s\S]*\}/);
      if (jsonMatch) {
        try {
          const parsed = JSON.parse(jsonMatch[0]);
          if (parsed.tool && parsed.args) {
            toolCalls.push({
              tool: parsed.tool,
              args: typeof parsed.args === 'string' ? JSON.parse(parsed.args) : parsed.args,
              reasoning: parsed.reasoning,
            });
          }
        } catch {
          // Not valid JSON
        }
      }
    }
    if (toolCalls.length > 0) {
      console.log('[Parser] Tool calls from code blocks:', JSON.stringify(toolCalls));
      return toolCalls;
    }
  }

  // Also try plain JSON objects with tool/args keys
  // Match complete JSON objects - find {"tool":...} patterns
  const jsonRegex = /\{"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*\{([^}]+)\}\s*\}/g;
  let jsonMatch;
  while ((jsonMatch = jsonRegex.exec(cleanedResponse)) !== null) {
    const toolName = jsonMatch[1];
    const argsStr = jsonMatch[2];
    // Parse args
    const args: Record<string, string> = {};
    const argRegex = /"([^"]+)"\s*:\s*"([^"]*)"/g;
    let argMatch;
    while ((argMatch = argRegex.exec(argsStr)) !== null) {
      args[argMatch[1]] = argMatch[2];
    }
    if (Object.keys(args).length > 0) {
      toolCalls.push({
        tool: toolName,
        args,
        reasoning: 'Extracted from text',
      });
    }
  }
  console.log('[Parser] Extracted tool calls from regex:', JSON.stringify(toolCalls));
  if (toolCalls.length > 0) {
    return toolCalls;
  }

  console.log('[Parser] Final tool calls:', JSON.stringify(toolCalls));
  return toolCalls;
}

// Parse XML-like tool call formats that DeepSeek often uses
function parseXmlToolCalls(response: string): ToolCallRequest[] {
  const toolCalls: ToolCallRequest[] = [];

  // Pattern: <<<tool_name args>>>, <<tool_name args>>, <tool_name args>
  const xmlPatterns = [
    /<<<(\w+)([^>]*)>>>/g,
    /<<(\w+)([^>]*)>>/g,
    /<(\w+)([^>]+)>/g,
  ];

  for (const pattern of xmlPatterns) {
    let match;
    while ((match = pattern.exec(response)) !== null) {
      const toolName = match[1].toLowerCase();
      const argsStr = match[2].trim();

      if (TOOL_NAMES.includes(toolName)) {
        const args = parseXmlArgs(toolName, argsStr);
        if (Object.keys(args).length > 0) {
          toolCalls.push({
            tool: toolName,
            args,
            reasoning: 'Extracted from XML-like format',
          });
        }
      }
    }
  }

  return toolCalls;
}

function parseXmlArgs(toolName: string, argsStr: string): Record<string, unknown> {
  const args: Record<string, unknown> = {};

  // Try to parse key="value" or key=value patterns
  const keyValuePattern = /(\w+)[\s:=]+["']?([^"'\n]+)["']?/g;
  let match;
  while ((match = keyValuePattern.exec(argsStr)) !== null) {
    const key = match[1].toLowerCase();
    let value = match[2].trim();

    // Remove trailing characters
    value = value.replace(/[>,}\]]+$/, '');

    // Try to parse as number
    const numValue = parseFloat(value);
    if (!isNaN(numValue) && value.match(/^[\d.]+$/)) {
      args[key] = numValue;
    } else {
      args[key] = value;
    }
  }

  return args;
}

export function hasToolCallIntent(response: string): boolean {
  const lower = response.toLowerCase();
  const indicators = [
    'need to',
    'i need to',
    'let me',
    "i'll",
    'i will',
    'should',
    'could use',
    'can use',
    'tool:',
    'using',
    'execute',
    'run the',
    'check the',
    'look at',
    'read the',
    'list',
    'directory',
    'file',
    'calculate',
  ];

  return indicators.some(ind => lower.includes(ind));
}

orchestrator.ts

Path: starbot_api/src/services/orchestrator/orchestrator.ts

Size: 6438 bytes

// DeepSeek Orchestrator
// Main orchestrator class that handles the "think first, execute later" pattern

import { getProvider } from '../../providers/index.js';
import type { Provider, ProviderMessage } from '../../providers/types.js';
import { parseToolCallsFromResponse, hasToolCallIntent } from './parser.js';
import { ToolExecutor } from './executor.js';
import type { ToolCallRequest, ExecutionResult, OrchestratorOptions, OrchestratorMessage } from './types.js';
import { env } from '../../env.js';

const SYSTEM_PROMPT = `You are a tool-using assistant. When you need to use a tool, you MUST respond with ONLY raw JSON - no other text.

Output format (raw JSON, no markdown):
{"tool":"tool_name","args":{"param":"value"}}

Tools:
- list_directory: {"args":{"path":"/directory/path"}}
- read_file: {"args":{"path":"/file/path"}}
- glob: {"args":{"pattern":"*.ts"}}
- bash: {"args":{"command":"ls -la"}}
- calculator: {"args":{"expression":"2+2"}}
- web_search: {"args":{"query":"search term"}}

IMPORTANT: Output ONLY the JSON. No explanations. No thinking. Just JSON.
If you don't need a tool, respond with plain text (not JSON).`;

export class DeepSeekOrchestrator {
  private provider: Provider;
  private executor: ToolExecutor;
  private maxIterations: number;
  private includeReasoning: boolean;
  private modelName: string;

  constructor(modelId: string, options: OrchestratorOptions = {}) {
    const modelParts = modelId.split(':');
    const providerName = modelParts[0] || 'azure';
    const deploymentName = modelParts[1] || modelId;

    this.modelName = deploymentName;
    this.provider = getProvider(providerName);
    this.executor = new ToolExecutor(options.timeoutMs || 30000);
    this.maxIterations = options.maxIterations || 5;
    this.includeReasoning = options.includeReasoning ?? true;
  }

  async run(
    userInput: string,
    messages: OrchestratorMessage[] = [],
    context?: { workspaceId?: string; projectId?: string }
  ): Promise<{
    response: string;
    toolResults: ExecutionResult[];
    iterations: number;
  }> {
    const toolResults: ExecutionResult[] = [];
    const conversation: ProviderMessage[] = this.buildConversation(userInput, messages);

    let iteration = 0;
    let lastResponse = '';
    let needsMoreWork = true;

    while (needsMoreWork && iteration < this.maxIterations) {
      iteration++;

      // Call DeepSeek
      const result = await this.callModel(conversation);
      lastResponse = result.response;

      // Check for tool calls from the model (native function calling)
      let toolCalls = result.toolCalls.map((tc: any, i: number) => ({
        id: tc.id || `call_${i}`,
        tool: tc.function?.name || tc.name,
        args: typeof tc.function?.arguments === 'string'
          ? JSON.parse(tc.function.arguments)
          : tc.function?.arguments || tc.arguments || {},
        reasoning: 'From native function call',
      }));

      // If no native tool calls, try parsing from text
      if (toolCalls.length === 0) {
        const parsedCalls = parseToolCallsFromResponse(result.response);
        toolCalls = parsedCalls.map((tc: any, i: number) => ({
          id: `call_${i}`,
          ...tc,
          reasoning: tc.reasoning || 'From text parsing',
        }));
      }

      if (toolCalls.length === 0) {
        // No tool calls, we're done
        needsMoreWork = false;
      } else {
        // Execute tools
        const execResults = await this.executor.executeAll(toolCalls);
        toolResults.push(...execResults);

        // Add assistant message with tool_calls FIRST (required by DeepSeek)
        conversation.push({
          type: 'message',
          role: 'assistant' as const,
          content: lastResponse,
          tool_calls: toolCalls.map((tc: any) => ({
            id: tc.id,
            name: tc.tool,
            arguments: JSON.stringify(tc.args),
          })),
        });

        // Add tool results to conversation - include tool_call_id for DeepSeek
        for (let i = 0; i < toolCalls.length; i++) {
          const tc = toolCalls[i];
          const res = execResults[i];
          conversation.push({
            type: 'message',
            role: 'tool' as const,
            tool_call_id: tc.id || `call_${i}`,
            name: tc.tool,
            content: res.success
              ? res.result
              : `Error: ${res.error}`,
          });
        }

        // Ask DeepSeek to synthesize
        conversation.push({
          type: 'message',
          role: 'user' as const,
          content: 'Based on the tool results above, provide your final answer to the user.',
        });
      }
    }

    // Clean up the final response
    const cleanedResponse = this.cleanResponse(lastResponse);

    return {
      response: cleanedResponse,
      toolResults,
      iterations: iteration,
    };
  }

  private buildConversation(
    userInput: string,
    existingMessages: OrchestratorMessage[]
  ): ProviderMessage[] {
    // For now, just use system prompt + current message to avoid tool_call_id issues
    // TODO: Properly handle conversation history with tool messages
    const messages: ProviderMessage[] = [
      { type: 'message', role: 'system', content: SYSTEM_PROMPT },
      { type: 'message', role: 'user', content: userInput },
    ];

    return messages;
  }

  private async callModel(messages: ProviderMessage[]): Promise<{ response: string; toolCalls: any[] }> {
    let fullResponse = '';
    let toolCalls: any[] = [];

    // Use the model from the orchestrator
    for await (const chunk of this.provider.sendChatStream(messages, {
      model: this.modelName,
      maxTokens: 8192,
      temperature: 0.7,
    })) {
      if (chunk.text) {
        fullResponse += chunk.text;
      }
      if (chunk.tool_calls && chunk.tool_calls.length > 0) {
        toolCalls = chunk.tool_calls;
      }
    }

    return { response: fullResponse, toolCalls };
  }

  private cleanResponse(response: string): string {
    return response
      // Remove thinking tags
      .replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
      // Remove JSON blocks we added
      .replace(/```json\s*\{[\s\S]*?\}\s*```/g, '')
      .replace(/\{[\s\S]*?"tool"[\s\S]*?\}/g, '')
      .trim();
  }
}

export function createOrchestrator(
  modelId: string,
  options?: OrchestratorOptions
): DeepSeekOrchestrator {
  return new DeepSeekOrchestrator(modelId, options);
}

executor.ts

Path: starbot_api/src/services/orchestrator/executor.ts

Size: 3370 bytes

// Tool Executor
// Executes tools requested by the orchestrator

import { toolRegistry } from '../tools/index.js';
import type { ToolCallRequest, ExecutionResult } from './types.js';

const TOOL_NAME_MAPPING: Record<string, string> = {
  'read_file': 'file.read',
  'write_file': 'fs.write_file',
  'edit_file': 'fs.edit_file',
  'list_directory': 'fs.glob',
  'glob': 'fs.glob',
  'grep': 'fs.grep',
  'calculator': 'calculator',
  'web_search': 'web_search',
  'bash': 'shell.exec',
  'shell': 'shell.exec',
  'run_command': 'shell.exec',
};

export class ToolExecutor {
  private timeoutMs: number;

  constructor(timeoutMs: number = 30000) {
    this.timeoutMs = timeoutMs;
  }

  async execute(toolCall: ToolCallRequest): Promise<ExecutionResult> {
    const startTime = Date.now();
    const toolName = TOOL_NAME_MAPPING[toolCall.tool] || toolCall.tool;

    try {
      const tool = toolRegistry.get(toolName);

      if (!tool) {
        return {
          tool: toolCall.tool,
          success: false,
          result: '',
          error: `Tool "${toolCall.tool}" not found`,
          durationMs: Date.now() - startTime,
        };
      }

      // Execute the tool
      const result = await this.executeTool(tool, toolCall.args);

      return {
        tool: toolCall.tool,
        success: true,
        result: this.formatResult(result),
        durationMs: Date.now() - startTime,
      };
    } catch (error) {
      return {
        tool: toolCall.tool,
        success: false,
        result: '',
        error: error instanceof Error ? error.message : String(error),
        durationMs: Date.now() - startTime,
      };
    }
  }

  async executeAll(toolCalls: ToolCallRequest[]): Promise<ExecutionResult[]> {
    const results: ExecutionResult[] = [];

    for (const toolCall of toolCalls) {
      const result = await this.execute(toolCall);
      results.push(result);

      // Stop on first error unless it's non-critical
      if (!result.success && !this.isNonCriticalError(result.error || '')) {
        break;
      }
    }

    return results;
  }

  private async executeTool(tool: any, args: Record<string, unknown>): Promise<unknown> {
    // Get the handler function from the tool
    const handler = tool.handler || tool.execute;

    if (!handler) {
      throw new Error(`Tool ${tool.name} has no handler`);
    }

    // Execute with timeout
    const timeoutPromise = new Promise((_, reject) => {
      setTimeout(() => reject(new Error('Tool execution timeout')), this.timeoutMs);
    });

    const execPromise = Promise.resolve(handler(args));

    return Promise.race([execPromise, timeoutPromise]);
  }

  private formatResult(result: unknown): string {
    if (result === null || result === undefined) {
      return '';
    }

    if (typeof result === 'string') {
      return result;
    }

    if (typeof result === 'object') {
      return JSON.stringify(result, null, 2);
    }

    return String(result);
  }

  private isNonCriticalError(error: string): boolean {
    // Some errors are non-critical and we can continue
    const nonCritical = [
      'not found',
      'does not exist',
      'no such file',
      'permission denied',
    ];

    const lowerError = error.toLowerCase();
    return nonCritical.some(nc => lowerError.includes(nc));
  }
}

// Re-export for convenience
export { toolRegistry };

types.ts

Path: starbot_api/src/services/orchestrator/types.ts

Size: 690 bytes

// Orchestrator Types

export interface ToolCallRequest {
  tool: string;
  args: Record<string, unknown>;
  reasoning?: string;
}

export interface ExecutionResult {
  tool: string;
  success: boolean;
  result: string;
  error?: string;
  durationMs: number;
}

export interface OrchestratorContext {
  conversationId: string;
  messages: OrchestratorMessage[];
  toolHistory: ExecutionResult[];
}

export interface OrchestratorMessage {
  role: 'user' | 'assistant' | 'tool' | 'system';
  content: string;
  tool_call_id?: string;
  tool_calls?: ToolCallRequest[];
}

export interface OrchestratorOptions {
  maxIterations?: number;
  timeoutMs?: number;
  includeReasoning?: boolean;
}

index.ts

Path: starbot_api/src/services/orchestrator/index.ts

Size: 331 bytes

// Orchestrator Module - Main exports

export { DeepSeekOrchestrator, createOrchestrator } from './orchestrator.js';
export { parseToolCallsFromResponse } from './parser.js';
export { ToolExecutor } from './executor.js';
export type { OrchestratorOptions, ToolCallRequest, ExecutionResult, OrchestratorContext } from './types.js';

retrieval.test.ts

Path: starbot_api/src/services/__tests__/retrieval.test.ts

Size: 13997 bytes

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { prisma } from '../../db.js';
import {
  searchMemory,
  searchIdentityMemory,
  searchChatMemory,
  getRelevantContext,
  getIdentityContext,
  getChatMemoryContext,
} from '../retrieval.js';
import * as embeddingsModule from '../embeddings.js';
import { vi } from 'vitest';

describe('Retrieval Service', () => {
  let projectId: string;
  let workspaceId: string;
  let chatId: string;

  // Mock embedding function
  const mockEmbedding = (text: string): number[] => {
    // Simple deterministic mock: hash text to embedding
    let hash = 0;
    for (let i = 0; i < text.length; i++) {
      const char = text.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash; // Convert to 32-bit int
    }
    // Generate 1536-dimensional vector (same as OpenAI)
    const seed = Math.abs(hash);
    const embedding: number[] = [];
    for (let i = 0; i < 1536; i++) {
      embedding.push((Math.sin(seed + i * 0.1) + 1) / 2); // Values 0-1
    }
    return embedding;
  };

  beforeEach(async () => {
    // Create test project
    const project = await prisma.project.create({
      data: {
        name: `Test Project ${Date.now()}`,
      },
    });
    projectId = project.id;

    // Create test workspace
    const workspace = await prisma.workspace.create({
      data: {
        projectId,
        type: 'folder',
        identifier: '/test/workspace',
      },
    });
    workspaceId = workspace.id;

    // Create test chat
    const chat = await prisma.chat.create({
      data: {
        projectId,
        workspaceId,
        title: 'Test Chat',
      },
    });
    chatId = chat.id;

    // Mock the embeddings
    vi.spyOn(embeddingsModule, 'generateEmbedding').mockImplementation(async (text: string) => {
      return mockEmbedding(text);
    });
  });

  afterEach(async () => {
    vi.restoreAllMocks();
    // Cleanup
    if (chatId) {
      await prisma.memoryDocument.deleteMany({ where: { chatId } });
      await prisma.chat.delete({ where: { id: chatId } }).catch(() => {});
    }
    if (workspaceId) {
      await prisma.memoryDocument.deleteMany({ where: { workspaceId } });
      await prisma.workspace.delete({ where: { id: workspaceId } }).catch(() => {});
    }
    if (projectId) {
      await prisma.memoryDocument.deleteMany({ where: { projectId } });
      await prisma.project.delete({ where: { id: projectId } }).catch(() => {});
    }
  });

  describe('searchMemory', () => {
    it('should return top K chunks sorted by similarity', async () => {
      // Create memory document with chunks
      const memDoc = await prisma.memoryDocument.create({
        data: {
          projectId,
          scope: 'project',
          content: '# Project Memory\n\nThis is project context.',
        },
      });

      // Create chunks separately
      await prisma.memoryChunk.createMany({
        data: [
          {
            memoryId: memDoc.id,
            text: 'Project overview section',
            embeddingVector: JSON.stringify(mockEmbedding('Project overview')),
          },
          {
            memoryId: memDoc.id,
            text: 'Team members and roles',
            embeddingVector: JSON.stringify(mockEmbedding('Team information')),
          },
        ],
      });

      const results = await searchMemory('project team', projectId, undefined, 2);

      expect(results).toBeDefined();
      expect(Array.isArray(results)).toBe(true);
      if (results.length > 0) {
        expect(results[0]).toHaveProperty('chunkId');
        expect(results[0]).toHaveProperty('text');
        expect(results[0]).toHaveProperty('similarity');
        expect(results[0].similarity >= 0 && results[0].similarity <= 1).toBe(true);
      }
    });

    it('should filter by minimum similarity threshold', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          projectId,
          scope: 'project',
          content: 'Test content',
        },
      });

      await prisma.memoryChunk.create({
        data: {
          memoryId: memDoc.id,
          text: 'Completely unrelated content about cats and dogs',
          embeddingVector: JSON.stringify(mockEmbedding('cats dogs')),
        },
      });

      // Query with high similarity threshold for unrelated term
      const results = await searchMemory('quantum computing', projectId, undefined, 5, 0.9);

      // Should return empty or very few results due to high threshold
      expect(results.length).toBeLessThanOrEqual(1);
    });

    it('should return empty array for no matching documents', async () => {
      // Don't create any memory documents
      const results = await searchMemory('test query', projectId, undefined, 5);

      expect(results).toEqual([]);
    });

    it('should include workspace scope when workspaceId is provided', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          projectId: null,
          workspaceId,
          scope: 'workspace',
          content: 'Workspace context',
        },
      });

      await prisma.memoryChunk.create({
        data: {
          memoryId: memDoc.id,
          text: 'Workspace specific info',
          embeddingVector: JSON.stringify(mockEmbedding('workspace')),
        },
      });

      const results = await searchMemory('workspace', projectId, workspaceId, 5);

      expect(results.length).toBeGreaterThan(0);
      expect(results.some(r => r.scope === 'workspace')).toBe(true);
    });
  });

  describe('searchIdentityMemory', () => {
    it('should search identity scope documents', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          scope: 'identity',
          projectId: null,
          workspaceId: null,
          chatId: null,
          content: 'Identity information',
        },
      });

      await prisma.memoryChunk.create({
        data: {
          memoryId: memDoc.id,
          text: 'My name is Claude',
          embeddingVector: JSON.stringify(mockEmbedding('name Claude')),
        },
      });

      const results = await searchIdentityMemory('What is your name?', 3);

      expect(results).toBeDefined();
      expect(Array.isArray(results)).toBe(true);
    });

    it('should return empty array if no identity doc exists', async () => {
      // Clean up any existing identity docs first
      await prisma.memoryDocument.deleteMany({
        where: {
          scope: 'identity',
        },
      });

      const results = await searchIdentityMemory('test', 3);

      expect(results).toEqual([]);
    });
  });

  describe('searchChatMemory', () => {
    it('should search chat scope documents', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          scope: 'chat',
          projectId: null,
          workspaceId: null,
          chatId,
          content: 'Chat memory',
        },
      });

      await prisma.memoryChunk.create({
        data: {
          memoryId: memDoc.id,
          text: 'Previous discussion about databases',
          embeddingVector: JSON.stringify(mockEmbedding('databases')),
        },
      });

      const results = await searchChatMemory('Tell me about databases', chatId, 5);

      expect(results).toBeDefined();
      expect(Array.isArray(results)).toBe(true);
    });

    it('should return empty array if no chat doc exists', async () => {
      const results = await searchChatMemory('test', chatId, 5);

      expect(results).toEqual([]);
    });
  });

  describe('getRelevantContext', () => {
    it('should return formatted markdown context', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          projectId,
          scope: 'project',
          content: 'Project info',
        },
      });

      await prisma.memoryChunk.create({
        data: {
          memoryId: memDoc.id,
          text: 'Important project detail',
          embeddingVector: JSON.stringify(mockEmbedding('project')),
        },
      });

      const context = await getRelevantContext('project info', projectId, undefined, 5);

      expect(typeof context).toBe('string');
      if (context) {
        expect(context).toContain('# Relevant Memory');
        expect(context).toContain('Project');
      }
    });

    it('should return empty string if no results', async () => {
      const context = await getRelevantContext('nonexistent', projectId, undefined, 5);

      expect(context).toBe('');
    });
  });

  describe('getIdentityContext', () => {
    it('should return formatted identity markdown', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          scope: 'identity',
          projectId: null,
          workspaceId: null,
          chatId: null,
          content: '# IDENTITY.md\n\nI am an AI assistant.',
        },
      });

      await prisma.memoryChunk.create({
        data: {
          memoryId: memDoc.id,
          text: 'I am an AI assistant',
          embeddingVector: JSON.stringify(mockEmbedding('identity')),
        },
      });

      const context = await getIdentityContext('Who are you?', 3);

      expect(typeof context).toBe('string');
      if (context) {
        expect(context).toContain('IDENTITY');
      }
    });

    it('should return empty string if no identity doc', async () => {
      // Clean up any existing identity docs first
      await prisma.memoryDocument.deleteMany({
        where: {
          scope: 'identity',
        },
      });

      const context = await getIdentityContext('test', 3);

      expect(context).toBe('');
    });
  });

  describe('getChatMemoryContext', () => {
    it('should return formatted chat markdown', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          scope: 'chat',
          projectId: null,
          workspaceId: null,
          chatId,
          content: '# Chat Memory\n\nPrevious context',
        },
      });

      await prisma.memoryChunk.create({
        data: {
          memoryId: memDoc.id,
          text: 'Previous context about topic',
          embeddingVector: JSON.stringify(mockEmbedding('previous')),
        },
      });

      // Search with term that matches the embedding
      const context = await getChatMemoryContext('previous', chatId, 5);

      expect(typeof context).toBe('string');
      if (context) {
        // When there are search results, context includes # MEMORY.md header
        expect(context).toContain('# MEMORY');
        expect(context).toContain('Previous');
      }
    });

    it('should return empty string if no chat doc', async () => {
      const context = await getChatMemoryContext('test', chatId, 5);

      expect(context).toBe('');
    });
  });

  describe('Cosine Similarity', () => {
    it('should correctly sort results by similarity', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          projectId,
          scope: 'project',
          content: 'Project content',
        },
      });

      await prisma.memoryChunk.createMany({
        data: [
          {
            memoryId: memDoc.id,
            text: 'Content about dogs and animals',
            embeddingVector: JSON.stringify(mockEmbedding('dogs animals')),
          },
          {
            memoryId: memDoc.id,
            text: 'Content about cats and animals',
            embeddingVector: JSON.stringify(mockEmbedding('cats animals')),
          },
          {
            memoryId: memDoc.id,
            text: 'Content about programming languages',
            embeddingVector: JSON.stringify(mockEmbedding('programming')),
          },
        ],
      });

      const results = await searchMemory('animals', projectId, undefined, 10);

      // Results should be sorted by similarity (highest first)
      for (let i = 1; i < results.length; i++) {
        expect(results[i - 1].similarity).toBeGreaterThanOrEqual(results[i].similarity);
      }
    });
  });

  describe('Edge Cases', () => {
    it('should handle chunks with null embeddings', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          projectId,
          scope: 'project',
          content: 'Content',
        },
      });

      await prisma.memoryChunk.createMany({
        data: [
          {
            memoryId: memDoc.id,
            text: 'Valid chunk',
            embeddingVector: JSON.stringify(mockEmbedding('test')),
          },
          {
            memoryId: memDoc.id,
            text: 'Invalid chunk with null embedding',
            embeddingVector: null,
          },
        ],
      });

      const results = await searchMemory('test', projectId, undefined, 5);

      // Should only return the valid chunk
      expect(results.length).toBeGreaterThan(0);
      expect(results.every(r => r.chunkId)).toBe(true);
    });

    it('should handle empty chunks array', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          projectId,
          scope: 'project',
          content: 'Content without chunks',
        },
      });

      const results = await searchMemory('test', projectId, undefined, 5);

      expect(results).toEqual([]);
    });

    it('should respect topK limit', async () => {
      const memDoc = await prisma.memoryDocument.create({
        data: {
          projectId,
          scope: 'project',
          content: 'Content',
        },
      });

      await prisma.memoryChunk.createMany({
        data: Array.from({ length: 20 }, (_, i) => ({
          memoryId: memDoc.id,
          text: `Chunk ${i}`,
          embeddingVector: JSON.stringify(mockEmbedding(`chunk ${i}`)),
        })),
      });

      const results = await searchMemory('chunk', projectId, undefined, 5);

      expect(results.length).toBeLessThanOrEqual(5);
    });
  });
});

interpreter.test.ts

Path: starbot_api/src/services/__tests__/interpreter.test.ts

Size: 13436 bytes

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { interpretUserMessage } from '../interpreter.js';
import { env } from '../../env.js';
import * as interpreterModule from '../interpreter.js';

describe('Interpreter Service', () => {
  const originalEnabled = env.INTERPRETER_ENABLED;

  afterEach(() => {
    env.INTERPRETER_ENABLED = originalEnabled;
    vi.restoreAllMocks();
  });

  describe('Browse Intent', () => {
    it('should classify web search queries as browse', async () => {
      const result = await interpretUserMessage('What is the weather in Paris?');
      // If interpreter is disabled, uses heuristic; otherwise uses interpreter
      expect(['browse', 'chat']).toContain(result.primaryIntent);
      expect(result.shouldClarify).toBe(false);
    });

    it('should classify news requests as browse', async () => {
      const result = await interpretUserMessage('What are the latest news today?');
      // News contains keyword 'news' which triggers browse heuristic
      expect(result.primaryIntent).toMatch(/browse|chat/);
    });

    it('should classify search requests as browse', async () => {
      const result = await interpretUserMessage('Search the web for quantum computing');
      // "Search" keyword should trigger browse intent
      expect(result.primaryIntent).toMatch(/browse|chat/);
    });

    it('should detect "search online" as browse', async () => {
      env.INTERPRETER_ENABLED = false; // Force heuristic mode
      const result = await interpretUserMessage('search online for recipes');
      expect(result.primaryIntent).toBe('browse');
    });

    it('should detect "look up online" as browse', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('look up online for best practices');
      expect(result.primaryIntent).toBe('browse');
    });

    it('should detect "find online" as browse', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('find online information about AI');
      expect(result.primaryIntent).toBe('browse');
    });

    it('should detect "google" keyword as browse', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('google the latest news');
      expect(result.primaryIntent).toBe('browse');
    });

    it('should detect weather queries as browse', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage("what's the weather in Tokyo?");
      expect(result.primaryIntent).toBe('browse');
    });

    it('should detect time queries as browse', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('what is the current time in New York?');
      expect(result.primaryIntent).toBe('browse');
    });

    it('should detect price queries as browse', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('what is the price of Bitcoin today?');
      expect(result.primaryIntent).toBe('browse');
    });

    it('should detect "today" keyword as browse', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('what happened today in history?');
      expect(result.primaryIntent).toBe('browse');
    });

    it('should detect "current" keyword as browse', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('what is the current state of the market?');
      expect(result.primaryIntent).toBe('browse');
    });
  });

  describe('Filesystem Intent', () => {
    it('should classify filesystem commands as filesystem', async () => {
      const result = await interpretUserMessage('ls -la src/');
      // ls command should trigger filesystem (both heuristic and interpreter)
      expect(result.primaryIntent).toBe('filesystem');
    });

    it('should classify directory listing as filesystem', async () => {
      const result = await interpretUserMessage('List files in the current directory');
      expect(result.primaryIntent).toBe('filesystem');
    });

    it('should classify file reading as filesystem', async () => {
      // Use explicit filesystem keyword
      const result = await interpretUserMessage('Show me the file contents in the workspace directory');
      expect(result.primaryIntent).toBe('filesystem');
    });

    it('should classify pwd command as filesystem', async () => {
      const result = await interpretUserMessage('pwd');
      expect(result.primaryIntent).toBe('filesystem');
    });

    it('should detect "open file" as filesystem', async () => {
      env.INTERPRETER_ENABLED = false; // Force heuristic mode
      const result = await interpretUserMessage('open file config.json');
      expect(result.primaryIntent).toBe('filesystem');
    });

    it('should detect "read file" as filesystem', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('read file README.md');
      expect(result.primaryIntent).toBe('filesystem');
    });

    it('should detect "save as" as filesystem', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('save as report.txt');
      expect(result.primaryIntent).toBe('filesystem');
    });

    it('should detect "create file" as filesystem', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('create file test.js');
      expect(result.primaryIntent).toBe('filesystem');
    });

    it('should detect "delete file" as filesystem', async () => {
      env.INTERPRETER_ENABLED = false;
      const result = await interpretUserMessage('delete file old.txt');
      expect(result.primaryIntent).toBe('filesystem');
    });

    it('should detect common file commands (cat, mkdir, rm, cp, mv)', async () => {
      env.INTERPRETER_ENABLED = false;

      const catResult = await interpretUserMessage('cat file.txt');
      expect(catResult.primaryIntent).toBe('filesystem');

      const mkdirResult = await interpretUserMessage('mkdir new_folder');
      expect(mkdirResult.primaryIntent).toBe('filesystem');

      const rmResult = await interpretUserMessage('rm file.txt');
      expect(rmResult.primaryIntent).toBe('filesystem');

      const cpResult = await interpretUserMessage('cp source.txt dest.txt');
      expect(cpResult.primaryIntent).toBe('filesystem');

      const mvResult = await interpretUserMessage('mv old.txt new.txt');
      expect(mvResult.primaryIntent).toBe('filesystem');
    });
  });

  describe('Code Intent', () => {
    it('should classify code debugging as code', async () => {
      const result = await interpretUserMessage('Debug the bug in main.ts');
      // "debug" keyword triggers code intent
      expect(result.primaryIntent).toBe('code');
    });

    it('should classify refactoring as code', async () => {
      const result = await interpretUserMessage('Refactor this component for better performance');
      // "refactor" keyword triggers code intent
      expect(result.primaryIntent).toBe('code');
    });

    it('should classify fix requests as code', async () => {
      const result = await interpretUserMessage('Fix this code snippet');
      // "fix" keyword triggers code intent
      expect(result.primaryIntent).toBe('code');
    });
  });

  describe('Chat Intent (Default)', () => {
    it('should classify general knowledge questions as chat', async () => {
      // Use a question that won't trigger other intents
      const result = await interpretUserMessage('What is a variable in programming?');
      expect(['chat', 'code']).toContain(result.primaryIntent);
    });

    it('should classify explanations as chat', async () => {
      const result = await interpretUserMessage('What is machine learning?');
      expect(result.primaryIntent).toMatch(/chat|code/);
    });

    it('should classify general conversation as chat', async () => {
      const result = await interpretUserMessage('Tell me about the solar system');
      // This should default to chat if no other intent is detected
      expect(result.primaryIntent).toBeDefined();
    });
  });

  describe('Clarification Intent', () => {
    it('should clarify on empty message', async () => {
      const result = await interpretUserMessage('');
      expect(result.shouldClarify).toBe(true);
      expect(result.primaryIntent).toBe('clarify');
      expect(result.confidence).toBe(1);
    });

    it('should clarify on whitespace-only message', async () => {
      const result = await interpretUserMessage('   ');
      expect(result.shouldClarify).toBe(true);
      expect(result.primaryIntent).toBe('clarify');
    });

    it('should clarify on ambiguous request', async () => {
      const result = await interpretUserMessage('Do that thing');
      // Might clarify or use heuristic - just check it returns a valid response
      expect(result).toBeDefined();
      expect(result.primaryIntent).toBeDefined();
      expect(['clarify', 'chat']).toContain(result.primaryIntent);
    });
  });

  describe('Heuristic Fallback', () => {
    beforeEach(() => {
      env.INTERPRETER_ENABLED = false;
    });

    it('should use heuristic for filesystem when interpreter disabled', async () => {
      const result = await interpretUserMessage('List files in src/');
      expect(result.primaryIntent).toBe('filesystem');
      expect(result.reason).toBe('interpreter_disabled');
      expect(result.confidence).toBe(1);
    });

    it('should use heuristic for browse when interpreter disabled', async () => {
      const result = await interpretUserMessage('Search the web for cats');
      expect(result.primaryIntent).toBe('browse');
      expect(result.reason).toBe('interpreter_disabled');
    });

    it('should default to chat when interpreter disabled and no clear intent', async () => {
      const result = await interpretUserMessage('Tell me a story');
      expect(result.primaryIntent).toBe('chat');
      expect(result.reason).toBe('interpreter_disabled');
    });
  });

  describe('Filesystem Heuristic Override', () => {
    it('should override clarify with filesystem heuristic for explicit fs commands', async () => {
      // When interpreter might return clarify but heuristic strongly suggests filesystem
      const result = await interpretUserMessage('ls -la');
      expect(result.primaryIntent).toBe('filesystem');
      expect(result.shouldClarify).toBe(false);
    });

    it('should normalize user message', async () => {
      const result = await interpretUserMessage('What is the weather in Paris?');
      expect(result.normalizedUserMessage).toBeDefined();
      expect(typeof result.normalizedUserMessage).toBe('string');
    });
  });

  describe('Response Structure', () => {
    it('should include all required fields', async () => {
      const result = await interpretUserMessage('Test message');

      expect(result).toHaveProperty('shouldClarify');
      expect(result).toHaveProperty('normalizedUserMessage');
      expect(result).toHaveProperty('primaryIntent');
      expect(result).toHaveProperty('intents');
      expect(result).toHaveProperty('confidence');
      expect(result).toHaveProperty('reason');

      expect(typeof result.shouldClarify).toBe('boolean');
      expect(typeof result.normalizedUserMessage).toBe('string');
      expect(typeof result.primaryIntent).toBe('string');
      expect(Array.isArray(result.intents)).toBe(true);
      expect(typeof result.confidence).toBe('number');
      expect(result.confidence >= 0 && result.confidence <= 1).toBe(true);
    });

    it('should have valid intents in the array', async () => {
      const result = await interpretUserMessage('List files in src/');
      const validIntents = ['chat', 'browse', 'filesystem', 'code', 'tool', 'clarify'];

      expect(Array.isArray(result.intents)).toBe(true);
      for (const intent of result.intents) {
        expect(validIntents).toContain(intent);
      }
    });

    it('should include clarification question when shouldClarify is true', async () => {
      const result = await interpretUserMessage('');
      if (result.shouldClarify) {
        expect(result.clarificationQuestion).toBeDefined();
        expect(typeof result.clarificationQuestion).toBe('string');
      }
    });
  });

  describe('Edge Cases', () => {
    it('should handle very long messages', async () => {
      const longMsg = 'a'.repeat(5000) + ' list files';
      const result = await interpretUserMessage(longMsg);
      expect(result).toBeDefined();
      expect(result.primaryIntent).toBeDefined();
    });

    it('should handle special characters', async () => {
      const result = await interpretUserMessage('List files: !@#$%^&*()');
      expect(result).toBeDefined();
      expect(result.primaryIntent).toBeDefined();
    });

    it('should handle mixed case', async () => {
      const result = await interpretUserMessage('WHAT IS THE WEATHER IN Paris???');
      expect(result).toBeDefined();
      expect(['browse', 'chat']).toContain(result.primaryIntent);
    });

    it('should handle unicode characters', async () => {
      const result = await interpretUserMessage('What is the weather in Paris? 🌦️');
      expect(result).toBeDefined();
      expect(result.primaryIntent).toBeDefined();
    });
  });
});

chunking.test.ts

Path: starbot_api/src/services/__tests__/chunking.test.ts

Size: 1454 bytes

import { describe, it, expect } from 'vitest';
import { chunkMarkdown, processContent } from '../chunking.js';

describe('Chunking Service', () => {
  describe('chunkMarkdown', () => {
    it('should split markdown by headings', () => {
      const content = `# Heading 1

Content for heading 1.

## Heading 2

Content for heading 2.`;

      const chunks = chunkMarkdown(content);

      expect(chunks.length).toBeGreaterThan(0);
      expect(chunks[0].heading).toBe('Heading 1');
    });

    it('should respect max tokens', () => {
      const content = 'a'.repeat(5000); // Very long content
      const chunks = chunkMarkdown(content, 800);

      chunks.forEach(chunk => {
        expect(chunk.tokens).toBeLessThanOrEqual(800);
      });
    });

    it('should handle empty content', () => {
      const chunks = chunkMarkdown('');
      expect(chunks).toEqual([]);
    });
  });

  describe('processContent', () => {
    it('should chunk and split large sections', () => {
      const content = `# Section

${'Very long text. '.repeat(500)}`;

      const chunks = processContent(content, 800);

      chunks.forEach(chunk => {
        expect(chunk.tokens).toBeLessThanOrEqual(800);
      });
    });

    it('should preserve heading context', () => {
      const content = `# Important Section

Important information.`;

      const chunks = processContent(content);

      expect(chunks[0].heading).toBe('Important Section');
    });
  });
});

TaskManager.ts

Path: starbot_api/src/services/task-manager/TaskManager.ts

Size: 11437 bytes

import { prisma } from '../../db.js';
import type {
  TaskCreateInput,
  TaskUpdateInput,
  TaskFilters,
  TaskWithEvents,
  TaskOperationResult,
} from './types.js';
import type { TaskEvent } from '@prisma/client';

export class TaskManager {
  /**
   * Create a new task
   */
  async createTask(input: TaskCreateInput): Promise<TaskOperationResult> {
    try {
      const task = await prisma.task.create({
        data: {
          title: input.title,
          description: input.description,
          priority: input.priority ?? 0,
          due_date: input.dueDate,
          estimated_hours: input.estimatedHours,
          chat_id: input.chatId,
          parent_id: input.parentId,
          metadata: input.metadata ? JSON.stringify(input.metadata) : null,
        },
      });

      // Handle dependencies if provided
      if (input.dependencies && input.dependencies.length > 0) {
        await prisma.taskDependency.createMany({
          data: input.dependencies.map(depId => ({
            task_id: task.id,
            dependency_id: depId,
          })),
        });
      }

      // Create initial event
      await this.createTaskEvent(task.id, 'created', { title: task.title });

      return {
        success: true,
        task: {
          ...task,
          events: [],
          children: [],
          dependencies: input.dependencies,
        } as TaskWithEvents,
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to create task',
      };
    }
  }

  /**
   * Get a task by ID
   */
  async getTaskById(taskId: string): Promise<TaskOperationResult> {
    try {
      const task = await prisma.task.findUnique({
        where: { id: taskId },
      });

      if (!task) {
        return {
          success: false,
          error: 'Task not found',
        };
      }

      return {
        success: true,
        task: {
          ...task,
          events: [],
          children: [],
        } as TaskWithEvents,
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to get task',
      };
    }
  }

  /**
   * List tasks with filters
   */
  async listTasks(filters: TaskFilters = {}): Promise<TaskOperationResult> {
    try {
      const where: any = {};

      if (filters.status) {
        where.status = filters.status;
      }

      if (filters.priority !== undefined) {
        where.priority = filters.priority;
      }

      if (filters.chatId) {
        where.chat_id = filters.chatId;
      }

      if (filters.parentId) {
        where.parent_id = filters.parentId;
      }

      if (filters.createdAfter) {
        where.created_at = { gte: filters.createdAfter };
      }

      if (filters.createdBefore) {
        where.created_at = { lte: filters.createdBefore };
      }

      if (filters.dueAfter) {
        where.due_date = { gte: filters.dueAfter };
      }

      if (filters.dueBefore) {
        where.due_date = { lte: filters.dueBefore };
      }

      const tasks = await prisma.task.findMany({
        where,
        orderBy: [
          { priority: 'desc' },
          { created_at: 'desc' },
        ],
              });

      return {
        success: true,
        tasks: tasks as TaskWithEvents[],
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to list tasks',
      };
    }
  }

  /**
   * Update a task
   */
  async updateTask(taskId: string, input: TaskUpdateInput): Promise<TaskOperationResult> {
    try {
      // Get current task for change detection
      const currentTask = await prisma.task.findUnique({
        where: { id: taskId },
        select: { status: true, title: true },
      });

      if (!currentTask) {
        return {
          success: false,
          error: 'Task not found',
        };
      }

      const updateData: any = {
        ...input,
        metadata: input.metadata ? JSON.stringify(input.metadata) : undefined,
      };

      // Only update completedAt if status is changing to COMPLETED
      if (input.status === 'COMPLETED' && currentTask.status !== 'COMPLETED') {
        updateData.completed_at = new Date();
      }

      const task = await prisma.task.update({
        where: { id: taskId },
        data: updateData,
      });

      // Create events for status changes
      if (input.status && input.status !== currentTask.status) {
        await this.createTaskEvent(task.id, 'status_changed', {
          from: currentTask.status,
          to: input.status,
        });
      }

      if (input.title && input.title !== currentTask.title) {
        await this.createTaskEvent(task.id, 'title_changed', {
          from: currentTask.title,
          to: input.title,
        });
      }

      return {
        success: true,
        task: {
          ...task,
          events: [],
          children: [],
        } as TaskWithEvents,
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to update task',
      };
    }
  }

  /**
   * Delete a task
   */
  async deleteTask(taskId: string): Promise<TaskOperationResult> {
    try {
      // Delete all child tasks first (cascade delete will handle this)
      await prisma.task.delete({
        where: { id: taskId },
      });

      return {
        success: true,
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to delete task',
      };
    }
  }

  /**
   * Mark task as in progress
   */
  async startTask(taskId: string): Promise<TaskOperationResult> {
    return this.updateTask(taskId, { status: 'IN_PROGRESS' });
  }

  /**
   * Complete a task
   */
  async completeTask(taskId: string): Promise<TaskOperationResult> {
    return this.updateTask(taskId, { status: 'COMPLETED' });
  }

  /**
   * Cancel a task
   */
  async cancelTask(taskId: string): Promise<TaskOperationResult> {
    return this.updateTask(taskId, { status: 'CANCELLED' });
  }

  /**
   * Create a subtask
   */
  async createSubtask(
    parentId: string,
    input: Omit<TaskCreateInput, 'parentId'>
  ): Promise<TaskOperationResult> {
    return this.createTask({
      ...input,
      parentId,
    });
  }

  /**
   * Add dependencies to a task
   */
  async addDependencies(taskId: string, dependencyIds: string[]): Promise<TaskOperationResult> {
    try {
      // Check if all dependencies exist
      const existingTasks = await prisma.task.findMany({
        where: {
          id: { in: dependencyIds },
        },
      });

      if (existingTasks.length !== dependencyIds.length) {
        return {
          success: false,
          error: 'One or more dependencies not found',
        };
      }

      // Add dependencies (insert individually to handle duplicates gracefully)
      for (const depId of dependencyIds) {
        try {
          await prisma.taskDependency.create({
            data: {
              task_id: taskId,
              dependency_id: depId,
            },
          });
        } catch {
          // Skip duplicate dependencies
        }
      }

      return {
        success: true,
        message: `Added ${dependencyIds.length} dependencies to task ${taskId}`,
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to add dependencies',
      };
    }
  }

  /**
   * Remove dependencies from a task
   */
  async removeDependencies(taskId: string, dependencyIds: string[]): Promise<TaskOperationResult> {
    try {
      await prisma.taskDependency.deleteMany({
        where: {
          task_id: taskId,
          dependency_id: { in: dependencyIds },
        },
      });

      return {
        success: true,
        message: `Removed ${dependencyIds.length} dependencies from task ${taskId}`,
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to remove dependencies',
      };
    }
  }

  /**
   * Get task dependencies
   */
  async getTaskDependencies(taskId: string): Promise<TaskOperationResult> {
    try {
      const dependencies = await prisma.taskDependency.findMany({
        where: { task_id: taskId },
        include: {
          dependency: true,
        },
      });

      return {
        success: true,
        dependencies: dependencies.map(d => d.dependency),
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to get dependencies',
      };
    }
  }

  /**
   * Get task dependents
   */
  async getTaskDependents(taskId: string): Promise<TaskOperationResult> {
    try {
      const dependents = await prisma.taskDependency.findMany({
        where: { dependency_id: taskId },
        include: {
          task: true,
        },
      });

      return {
        success: true,
        dependents: dependents.map(d => d.task),
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to get dependents',
      };
    }
  }

  /**
   * Get task hierarchy (all descendants)
   */
  async getTaskHierarchy(taskId: string): Promise<TaskOperationResult> {
    try {
      const task = await prisma.task.findUnique({
        where: { id: taskId },
        include: {
          subtasks: {
            include: {
              subtasks: true,
            },
          },
        },
      });

      if (!task) {
        return {
          success: false,
          error: 'Task not found',
        };
      }

      return {
        success: true,
        task: {
          ...task,
          events: [],
          children: (task.subtasks || []) as any[],
        } as TaskWithEvents,
      };
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Failed to get task hierarchy',
      };
    }
  }

  /**
   * Create a task event
   */
  private async createTaskEvent(
    taskId: string,
    type: string,
    data?: any
  ): Promise<TaskEvent> {
    return prisma.taskEvent.create({
      data: {
        taskId,
        type,
        data: data ? JSON.stringify(data) : null,
      },
    });
  }

  /**
   * Get task analytics
   */
  async getTaskAnalytics(chatId?: string) {
    const where: any = {};
    if (chatId) {
      where.chat_id = chatId;
    }

    const [
      totalTasks,
      pendingTasks,
      inProgressTasks,
      completedTasks,
      cancelledTasks,
      avgPriority,
    ] = await Promise.all([
      prisma.task.count({ where }),
      prisma.task.count({ where: { ...where, status: 'PENDING' } }),
      prisma.task.count({ where: { ...where, status: 'IN_PROGRESS' } }),
      prisma.task.count({ where: { ...where, status: 'COMPLETED' } }),
      prisma.task.count({ where: { ...where, status: 'CANCELLED' } }),
      prisma.task.aggregate({
        where,
        _avg: { priority: true },
      }),
    ]);

    return {
      total: totalTasks,
      pending: pendingTasks,
      inProgress: inProgressTasks,
      completed: completedTasks,
      cancelled: cancelledTasks,
      averagePriority: avgPriority._avg.priority || 0,
    };
  }
}

types.ts

Path: starbot_api/src/services/task-manager/types.ts

Size: 1436 bytes

import type { Task, TaskEvent } from '@prisma/client';

export interface TaskCreateInput {
  title: string;
  description?: string;
  priority?: number;
  dueDate?: Date;
  estimatedHours?: number;
  chatId?: string;
  parentId?: string;
  dependencies?: string[];
  metadata?: any;
}

export type TaskStatus = 'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED';

export interface TaskUpdateInput {
  title?: string;
  description?: string;
  status?: TaskStatus;
  priority?: number;
  dueDate?: Date;
  estimatedHours?: number;
  actualHours?: number;
  metadata?: any;
  completedAt?: Date;
}

export interface TaskFilters {
  status?: TaskStatus;
  priority?: number;
  chatId?: string;
  parentId?: string;
  createdAfter?: Date;
  createdBefore?: Date;
  dueAfter?: Date;
  dueBefore?: Date;
}

export interface TaskWithEvents {
  id: string;
  title: string;
  description?: string;
  status: TaskStatus;
  priority: number;
  due_date?: Date;
  estimated_hours?: number;
  actual_hours?: number;
  parent_id?: string;
  chat_id?: string;
  metadata?: any;
  created_at: Date;
  updated_at: Date;
  completed_at?: Date;
  events?: TaskEvent[];
  children?: TaskWithEvents[];
  dependencies?: string[];
}

export interface TaskOperationResult {
  success: boolean;
  task?: TaskWithEvents;
  tasks?: TaskWithEvents[];
  error?: string;
  message?: string;
  events?: TaskEvent[];
  dependencies?: any[];
  dependents?: any[];
}

index.ts

Path: starbot_api/src/services/task-manager/index.ts

Size: 176 bytes

export { TaskManager } from './TaskManager.js';
export type {
  TaskCreateInput,
  TaskUpdateInput,
  TaskFilters,
  TaskWithEvents,
  TaskOperationResult,
} from './types.js';

signals.ts

Path: starbot_api/src/services/triage/signals.ts

Size: 5984 bytes

// Stage A: Signal Detection
// Detect patterns in user messages without external calls

import type { TriageInput, TriageSignals } from './types.js';

// Regex patterns for detection
const CODE_BLOCK_REGEX = /```(?:[\w.+-]+)?\n?([\s\S]*?)```/g;
const STACK_TRACE_REGEX = /(^\s*at\s+\S+|Traceback\s*\(most recent call last\)|Exception:|Error:|panic:|FATAL|Caused by:|\bTS\d{3,5}\b|\bERR_[A-Z_]+\b)/m;
const COMMAND_LINE_REGEX = /^\s*(\$|>|PS>|sudo\b)/im;

const IMPLEMENT_REQUEST_REGEX = /\b(implement|refactor|add feature|add endpoint|build|create|code this|change this code|update this code|write code)\b/i;
const EXPLANATION_REQUEST_REGEX = /\b(explain|what does|how does|walk me through|clarify|why does|describe)\b/i;
const SUMMARY_REQUEST_REGEX = /\b(summarize|summary|tldr|tl;dr|overview|recap|key points|gist)\b/i;
const REWRITE_REQUEST_REGEX = /\b(rewrite|rephrase|polish|edit this|improve wording|tighten|reformat)\b/i;
const EXTRACTION_REQUEST_REGEX = /\b(extract|parse|structured|json|table|checklist|schema|convert to)\b/i;
const COMPARE_REQUEST_REGEX = /\b(compare|versus|vs\.?|difference|tradeoff|trade-off|pros and cons|recommend|which is better)\b/i;
const PLAN_REQUEST_REGEX = /\b(plan|design|architecture|architect|roadmap|approach|spec|outline)\b/i;
const DEBUG_REQUEST_REGEX = /\b(debug|bug|broken|not working|fix error|fails?|crash|error)\b/i;
const BRAINSTORM_REQUEST_REGEX = /\b(brainstorm|ideas|options|alternatives|possibilities|suggestions)\b/i;
const QUICK_REQUEST_REGEX = /\b(quick|brief|short|concise|simple)\b/i;
const DEEP_REQUEST_REGEX = /\b(deep|thorough|detailed|in-depth|comprehensive|production-ready|extensive)\b/i;

const LANGUAGE_HINTS: Array<{ language: string; regex: RegExp }> = [
  { language: 'typescript', regex: /(\.tsx?\b|```\s*ts\b|```\s*typescript\b|\binterface\s+\w+)/i },
  { language: 'javascript', regex: /(\.jsx?\b|```\s*js\b|```\s*javascript\b|\bmodule\.exports\b)/i },
  { language: 'python', regex: /(\.py\b|```\s*py\b|```\s*python\b|\bdef\s+\w+\()/i },
  { language: 'go', regex: /(\.go\b|```\s*go\b|\bpackage\s+main\b)/i },
  { language: 'rust', regex: /(\.rs\b|```\s*rust\b|\bfn\s+main\s*\()/i },
  { language: 'java', regex: /(\.java\b|```\s*java\b|\bpublic\s+class\b)/i },
  { language: 'c', regex: /(\.c\b|```\s*c\b|\b#include\s*<)/i },
  { language: 'cpp', regex: /(\.c(pp|xx)?\b|\.h(pp)?\b|```\s*c\+\+\b)/i },
  { language: 'php', regex: /(\.php\b|```\s*php\b|<\?php)/i },
  { language: 'ruby', regex: /(\.rb\b|```\s*ruby\b|\bclass\s+\w+\s*<\s*\w+)/i },
  { language: 'shell', regex: /(\.sh\b|```\s*bash\b|```\s*shell\b|\bsudo\s+\w+)/i },
  { language: 'sql', regex: /(\.sql\b|```\s*sql\b|\bselect\s+.+\s+from\b)/i },
  { language: 'html', regex: /(\.html?\b|```\s*html\b|<html|<div)/i },
  { language: 'css', regex: /(\.css\b|```\s*css\b|\{\s*color\s*:)/i },
  { language: 'json', regex: /(\.json\b|```\s*json\b|"\w+"\s*:\s*\{?)/i },
  { language: 'yaml', regex: /(\.ya?ml\b|```\s*ya?ml\b|\w+:\s*\w+)/i },
];

function extractCodeSegments(message: string): string[] {
  const segments: string[] = [];
  let match: RegExpExecArray | null;

  const regex = new RegExp(CODE_BLOCK_REGEX);
  while ((match = regex.exec(message)) !== null) {
    if (match[1]) {
      segments.push(match[1]);
    }
  }

  return segments;
}

function detectLanguages(input: TriageInput, codeSegments: string[]): string[] {
  const haystack = [
    input.user_message,
    ...codeSegments,
    ...((input.attachments || []).map(a => a.filename || '')),
  ].join('\n');

  const languages = new Set<string>();

  for (const hint of LANGUAGE_HINTS) {
    if (hint.regex.test(haystack)) {
      languages.add(hint.language);
    }
  }

  return Array.from(languages);
}

export function detectSignals(input: TriageInput): TriageSignals {
  const message = input.user_message;
  const codeSegments = extractCodeSegments(message);
  const detectedLanguages = detectLanguages(input, codeSegments);

  const hasImage = (input.attachments || []).some(a => a.type === 'image');
  const hasFileAttachment = (input.attachments || []).some(a => a.type === 'file' || a.type === 'text');

  // Estimate tokens (rough: 1 token ≈ 4 characters)
  const estimatedInputTokens = Math.ceil(message.length / 4);
  const estimatedContextTokens = input.context_summary ? Math.ceil(input.context_summary.length / 4) : 0;

  // Compute complexity score (1-5)
  let complexityScore = 1;
  if (codeSegments.length > 0) complexityScore++;
  if (estimatedInputTokens > 2000) complexityScore++; // Long input
  if (detectedLanguages.length > 1) complexityScore++; // Multi-language
  if (DEEP_REQUEST_REGEX.test(message)) complexityScore++;
  complexityScore = Math.min(5, complexityScore);

  return {
    // Attachments
    has_image_attachment: hasImage,
    has_file_attachment: hasFileAttachment,
    has_code_block: codeSegments.length > 0,
    has_stack_trace: STACK_TRACE_REGEX.test(message),
    has_command_snippet: COMMAND_LINE_REGEX.test(message),

    // Intent signals
    asks_for_implementation: IMPLEMENT_REQUEST_REGEX.test(message),
    asks_for_explanation: EXPLANATION_REQUEST_REGEX.test(message),
    asks_for_summary: SUMMARY_REQUEST_REGEX.test(message),
    asks_for_rewrite: REWRITE_REQUEST_REGEX.test(message),
    asks_for_extraction: EXTRACTION_REQUEST_REGEX.test(message),
    asks_for_comparison: COMPARE_REQUEST_REGEX.test(message),
    asks_for_plan: PLAN_REQUEST_REGEX.test(message),
    asks_for_debug: DEBUG_REQUEST_REGEX.test(message),
    asks_for_brainstorm: BRAINSTORM_REQUEST_REGEX.test(message),
    asks_for_quick: QUICK_REQUEST_REGEX.test(message),
    asks_for_deep: DEEP_REQUEST_REGEX.test(message),

    // Complexity signals
    long_input: estimatedInputTokens > 2000,
    short_input: estimatedInputTokens < 100,
    detected_languages: detectedLanguages,
    complexity_score: complexityScore,

    // Metadata
    estimated_input_tokens: estimatedInputTokens,
    estimated_context_tokens: estimatedContextTokens,
  };
}

rules.ts

Path: starbot_api/src/services/triage/rules.ts

Size: 5446 bytes

// Stage B: Rules Engine
// Apply deterministic rules to signals → produce TriageDecision

import type { TriageSignals, TriageInput, TriageDecision, TriageCategory, TriageLane } from './types.js';

// Category scoring - higher score wins
function computeCategoryScores(signals: TriageSignals): Record<TriageCategory, number> {
  const scores: Record<TriageCategory, number> = {
    CHAT_QA: 1,           // Base score (default fallback)
    BRAINSTORM: 0,
    WRITE_REWRITE: 0,
    SUMMARIZE: 0,
    EXTRACT_STRUCTURE: 0,
    RESEARCH_COMPARE: 0,
    PLAN_DESIGN: 0,
    CODE_EXPLAIN: 0,
    DEBUG: 0,
    CODE_CHANGE: 0,
    CLI_OPS: 0,
    META_TRACE: 0,
  };

  // Hard rules (highest priority)
  if (signals.has_stack_trace || signals.asks_for_debug) {
    scores.DEBUG += 10;
  }

  if (signals.asks_for_implementation) {
    scores.CODE_CHANGE += 8;
  }

  // Intent-based scoring
  if (signals.asks_for_summary) scores.SUMMARIZE += 5;
  if (signals.asks_for_rewrite) scores.WRITE_REWRITE += 5;
  if (signals.asks_for_extraction) scores.EXTRACT_STRUCTURE += 5;
  if (signals.asks_for_comparison) scores.RESEARCH_COMPARE += 5;
  if (signals.asks_for_plan) scores.PLAN_DESIGN += 5;
  if (signals.asks_for_brainstorm) scores.BRAINSTORM += 5;

  // Code-related scoring
  if (signals.has_code_block) {
    if (signals.asks_for_explanation) {
      scores.CODE_EXPLAIN += 6;
    } else if (signals.has_stack_trace) {
      scores.DEBUG += 5;
    } else {
      scores.CODE_CHANGE += 3;
    }
  }

  if (signals.has_command_snippet) {
    scores.CLI_OPS += 4;
  }

  // Multi-language = likely complex code task
  if (signals.detected_languages.length > 1) {
    scores.CODE_CHANGE += 2;
  }

  return scores;
}

function selectCategory(scores: Record<TriageCategory, number>): TriageCategory {
  let maxScore = 0;
  let selectedCategory: TriageCategory = 'CHAT_QA';

  for (const [category, score] of Object.entries(scores) as Array<[TriageCategory, number]>) {
    if (score > maxScore) {
      maxScore = score;
      selectedCategory = category;
    }
  }

  return selectedCategory;
}

function selectLane(
  signals: TriageSignals,
  complexity: number,
  input: TriageInput
): TriageLane {
  // User explicit request overrides everything
  if (input.mode === 'quick') return 'quick';
  if (input.mode === 'deep') return 'deep';

  // Signal-based overrides
  if (signals.asks_for_quick) return 'quick';
  if (signals.asks_for_deep) return 'deep';

  // Complexity-based selection
  if (complexity <= 2) return 'quick';
  if (complexity === 3) return 'standard';
  return 'deep'; // complexity >= 4
}

function buildReasonCodes(signals: TriageSignals, category: TriageCategory): string[] {
  const reasons: string[] = [];

  // Attachment reasons
  if (signals.has_image_attachment) reasons.push('HAS_IMAGE_ATTACHMENT');
  if (signals.has_code_block) reasons.push('HAS_CODE_BLOCK');
  if (signals.has_stack_trace) reasons.push('HAS_STACK_TRACE');
  if (signals.has_command_snippet) reasons.push('HAS_COMMAND_SNIPPET');

  // Intent reasons
  if (signals.asks_for_implementation) reasons.push('ASKS_FOR_IMPLEMENTATION');
  if (signals.asks_for_explanation) reasons.push('ASKS_FOR_EXPLANATION');
  if (signals.asks_for_summary) reasons.push('ASKS_FOR_SUMMARY');
  if (signals.asks_for_rewrite) reasons.push('ASKS_FOR_REWRITE');
  if (signals.asks_for_extraction) reasons.push('ASKS_FOR_EXTRACTION');
  if (signals.asks_for_comparison) reasons.push('ASKS_FOR_COMPARISON');
  if (signals.asks_for_plan) reasons.push('ASKS_FOR_PLAN');
  if (signals.asks_for_debug) reasons.push('ASKS_FOR_DEBUG');
  if (signals.asks_for_brainstorm) reasons.push('ASKS_FOR_BRAINSTORM');

  // Complexity reasons
  if (signals.long_input) reasons.push('LONG_INPUT');
  if (signals.short_input) reasons.push('SHORT_INPUT');
  if (signals.asks_for_quick) reasons.push('USER_WANTS_QUICK');
  if (signals.asks_for_deep) reasons.push('USER_WANTS_DEEP');
  if (signals.complexity_score >= 4) reasons.push('HIGH_COMPLEXITY');

  // Language reasons
  if (signals.detected_languages.length > 0) {
    reasons.push(`DETECTED_LANGUAGES:${signals.detected_languages.join(',')}`);
  }

  // Category reason
  reasons.push(`CATEGORY:${category}`);

  return reasons;
}

function buildSafetyNotes(signals: TriageSignals): string[] {
  const notes: string[] = [];

  // No specific safety concerns in simplified version
  // Can be extended later for security-sensitive content

  return notes;
}

export function applyRules(signals: TriageSignals, input: TriageInput): TriageDecision {
  // 1. Compute category scores
  const scores = computeCategoryScores(signals);

  // 2. Select winning category
  const category = selectCategory(scores);

  // 3. Estimate final complexity
  let complexity = signals.complexity_score;
  if (category === 'CODE_CHANGE' || category === 'DEBUG') {
    complexity = Math.min(5, complexity + 1); // Code tasks are inherently more complex
  }
  complexity = Math.max(1, Math.min(5, complexity));

  // 4. Select lane (tier)
  const lane = selectLane(signals, complexity, input);

  // 5. Check if vision is needed
  const needs_vision = signals.has_image_attachment;

  // 6. Build reason codes
  const reason_codes = buildReasonCodes(signals, category);

  // 7. Build safety notes
  const safety_notes = buildSafetyNotes(signals);

  return {
    category,
    lane,
    complexity,
    needs_vision,
    reason_codes,
    safety_notes,
  };
}

types.ts

Path: starbot_api/src/services/triage/types.ts

Size: 2110 bytes

// Triage System Types for Starbot_API
// Simplified version focused on core categorization and routing

export type TriageCategory =
  | 'CHAT_QA'           // Simple conversation
  | 'BRAINSTORM'        // Ideation
  | 'WRITE_REWRITE'     // Content writing
  | 'SUMMARIZE'         // Summarization
  | 'EXTRACT_STRUCTURE' // Data extraction
  | 'RESEARCH_COMPARE'  // Research/comparison
  | 'PLAN_DESIGN'       // Planning/design
  | 'CODE_EXPLAIN'      // Code explanation
  | 'DEBUG'             // Bug fixing
  | 'CODE_CHANGE'       // Code implementation
  | 'CLI_OPS'           // Command-line operations
  | 'META_TRACE';       // System queries

export type TriageLane =
  | 'quick'    // Tier 1: Fast & cheap
  | 'standard' // Tier 2: Balanced
  | 'deep';    // Tier 3: Premium

export interface TriageInput {
  user_message: string;
  attachments?: Array<{
    type: 'image' | 'text' | 'file';
    filename?: string;
  }>;
  context_summary?: string;
  mode?: 'quick' | 'standard' | 'deep'; // User-requested mode
}

export interface TriageSignals {
  // Attachments
  has_image_attachment: boolean;
  has_file_attachment: boolean;
  has_code_block: boolean;
  has_stack_trace: boolean;
  has_command_snippet: boolean;

  // Intent signals
  asks_for_implementation: boolean;
  asks_for_explanation: boolean;
  asks_for_summary: boolean;
  asks_for_rewrite: boolean;
  asks_for_extraction: boolean;
  asks_for_comparison: boolean;
  asks_for_plan: boolean;
  asks_for_debug: boolean;
  asks_for_brainstorm: boolean;
  asks_for_quick: boolean;
  asks_for_deep: boolean;

  // Complexity signals
  long_input: boolean;
  short_input: boolean;
  detected_languages: string[];
  complexity_score: number; // 1-5

  // Metadata
  estimated_input_tokens: number;
  estimated_context_tokens: number;
}

export interface TriageDecision {
  category: TriageCategory;
  lane: TriageLane;
  complexity: number; // 1-5
  needs_vision: boolean;
  reason_codes: string[];
  safety_notes: string[];
}

export interface TriageResult {
  decision: TriageDecision;
  signals: TriageSignals;
  elapsed_ms: number;
}

index.ts

Path: starbot_api/src/services/triage/index.ts

Size: 800 bytes

// Triage System Entry Point
// Runs Stage A (signal detection) then Stage B (rules engine)

import type { TriageInput, TriageResult } from './types.js';
import { detectSignals } from './signals.js';
import { applyRules } from './rules.js';

export function runTriage(input: TriageInput): TriageResult {
  const start = performance.now();

  // Stage A: Detect signals from user message
  const signals = detectSignals(input);

  // Stage B: Apply rules to produce decision
  const decision = applyRules(signals, input);

  const elapsed_ms = Math.round(performance.now() - start);

  return {
    decision,
    signals,
    elapsed_ms,
  };
}

// Re-export types for convenience
export type { TriageInput, TriageDecision, TriageSignals, TriageResult, TriageCategory, TriageLane } from './types.js';

remove_duplicates.py

Path: starbot_tui/remove_duplicates.py

Size: 2741 bytes

#!/usr/bin/env python3
"""
Remove duplicate handler functions from tui.rs
"""

def remove_function(lines, start_line, func_name):
    """Remove a function from lines, finding the matching closing brace."""
    # start_line is 0-indexed
    if start_line >= len(lines):
        return lines

    # Count braces to find function end
    brace_count = 0
    i = start_line
    started = False

    while i < len(lines):
        line = lines[i]
        for char in line:
            if char == '{':
                brace_count += 1
                started = True
            elif char == '}':
                brace_count -= 1
                if started and brace_count == 0:
                    # Found closing brace, delete from start_line to i (inclusive)
                    print(f"  Removing {func_name}: lines {start_line+1}-{i+1} ({i-start_line+1} lines)")
                    return lines[:start_line] + lines[i+1:]
        i += 1

    print(f"  WARNING: Could not find closing brace for {func_name}")
    return lines

# Read file
with open('src/commands/tui.rs', 'r') as f:
    lines = f.readlines()

print(f"Original file: {len(lines)} lines")

# Functions to remove (name, approximate line number - 1-indexed)
# Working from bottom to top to maintain line numbers
functions_to_remove = [
    ('extract_usage_line', 3389),
    ('extract_provider_model', 3363),
    ('extract_reply', 3352),
    ('send_chat_text', 1543),
    ('retry_last_chat', 1512),
    ('apply_workspace_selection', 1475),
    ('move_selection_wrap', 1459),
    ('move_selection', 1443),
    ('handle_key', 1020),
    ('handle_event', 1005),
    ('handle_tui_msg', 532),
    ('build_chat_body', 505),
    ('spawn_chat_request', 490),
    ('spawn_tool_propose', 467),
    ('spawn_workspaces_fetch', 460),
    ('spawn_health_fetch', 453),
    ('spawn_models_fetch', 446),
]

# Sort by line number descending (remove from bottom to top)
functions_to_remove.sort(key=lambda x: x[1], reverse=True)

for func_name, line_num in functions_to_remove:
    # Find the function in the file (search nearby in case line numbers shifted)
    found = False
    for offset in range(-5, 10):
        check_line = line_num - 1 + offset  # Convert to 0-indexed
        if check_line < 0 or check_line >= len(lines):
            continue
        if f'fn {func_name}(' in lines[check_line]:
            lines = remove_function(lines, check_line, func_name)
            found = True
            break

    if not found:
        print(f"  WARNING: Could not find {func_name} near line {line_num}")

# Write back
with open('src/commands/tui.rs', 'w') as f:
    f.writelines(lines)

print(f"\n✅ Done! New file: {len(lines)} lines")
print(f"   Removed: {len(functions_to_remove)} functions")

Cargo.toml

Path: starbot_tui/Cargo.toml

Size: 678 bytes

[package]
name = "starbot-tui"
version = "1.0.0"
edition = "2024"

[[bin]]
name = "starbot-tui"
path = "src/main.rs"

[dependencies]
clap = { version = "4.5.31", features = ["derive"] }
serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.139"
reqwest = { version = "0.12.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread", "time", "sync"] }
tokio-stream = "0.1"
futures = "0.3"
thiserror = "2.0.11"
dirs = "6.0.0"
url = "2.5.4"
rpassword = "7.3.1"
open = "5.3.2"
ratatui = "0.28.1"
crossterm = "0.28.1"
unicode-width = "0.1.14"
qrcode = "0.14"
hostname = "0.4"

README.md

Path: starbot_tui/README.md

Size: 1837 bytes

# starbott (Rust CLI)

Terminal client for Starbott backend APIs.

## Build

```bash
cd cli
cargo build --release
```

Binary output:

```bash
./target/release/starbott
```

## Config

Config file path:

- macOS/Linux: `~/.config/starbott/config.json`
- Windows: `%APPDATA%\\starbott\\config.json`

Initialize:

```bash
starbott config init --api-url http://localhost:3003
```

Set token:

```bash
starbott auth login --token "<jwt>"
```

## Commands

- `starbott config init|get|set|profiles|use`
- `starbott auth login|logout`
- `starbott workspaces create|list|permissions`
- `starbott tools propose|commit|deny|runs`
- `starbott whoami`
- `starbott chat "<prompt>" [--stdin] [-m|--model <selector>] [--stream]`
- `starbott tui [-m|--model <selector>]`
- `starbott usage [--since <value>] [--until <value>] [--group day|model|provider]`
- `starbott billing status`
- `starbott billing portal [--open]`
- `starbott health`

## TUI

Fullscreen chat UI for quick testing.

```bash
starbott tui
starbott tui -m vertex:gemini-3-flash-preview
```

Cute mode toggle (SPEC5):

Create `~/.starbott/config` with:

```ini
cute = on        # phrases + icons + spinner
# cute = minimal # icons/spinner only
# cute = off     # plain status text (no cute phrases)
```

Keys:

- `Enter` send
- `F2` or `Ctrl+M` model picker
- `PgUp/PgDn` scroll
- `Ctrl+R` reload `/v1/models`
- `Ctrl+D` toggle debug panel
- `Esc` quit

Convenience (dev) wrapper that logs in and runs the CLI:

```bash
./scripts/starbott-dev.sh tui
```

## Global flags

- `--profile <name>`
- `--api-url <url>`
- `--json`
- `--quiet`
- `--timeout <ms>` (default `30000`)
- `--retries <n>` (default `2`)
- `--verbose`
- `--debug`

## Exit codes

- `0` success
- `1` generic error
- `2` auth error
- `3` usage error
- `4` network error / timeout
- `5` rate limited
- `6` server error

Cargo.lock

Path: starbot_tui/Cargo.lock

Size: 55403 bytes

# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"

[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
 "anstyle",
 "anstyle-parse",
 "anstyle-query",
 "anstyle-wincon",
 "colorchoice",
 "is_terminal_polyfill",
 "utf8parse",
]

[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"

[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
 "utf8parse",
]

[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
 "windows-sys 0.61.2",
]

[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
 "anstyle",
 "once_cell_polyfill",
 "windows-sys 0.61.2",
]

[[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.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"

[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"

[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"

[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"

[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"

[[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.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"

[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"

[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
 "rustversion",
]

[[package]]
name = "cc"
version = "1.2.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
dependencies = [
 "find-msvc-tools",
 "shlex",
]

[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"

[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"

[[package]]
name = "clap"
version = "4.5.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
dependencies = [
 "clap_builder",
 "clap_derive",
]

[[package]]
name = "clap_builder"
version = "4.5.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
dependencies = [
 "anstream",
 "anstyle",
 "clap_lex",
 "strsim",
]

[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
 "heck",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "clap_lex"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"

[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"

[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
 "castaway",
 "cfg-if",
 "itoa",
 "rustversion",
 "ryu",
 "static_assertions",
]

[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
 "bitflags",
 "crossterm_winapi",
 "mio",
 "parking_lot",
 "rustix",
 "signal-hook",
 "signal-hook-mio",
 "winapi",
]

[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
 "winapi",
]

[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
 "darling_core",
 "darling_macro",
]

[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
 "ident_case",
 "proc-macro2",
 "quote",
 "strsim",
 "syn",
]

[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
 "darling_core",
 "quote",
 "syn",
]

[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
 "dirs-sys",
]

[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
 "libc",
 "option-ext",
 "redox_users",
 "windows-sys 0.61.2",
]

[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"

[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"

[[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 = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"

[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"

[[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 = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
 "futures-channel",
 "futures-core",
 "futures-executor",
 "futures-io",
 "futures-sink",
 "futures-task",
 "futures-util",
]

[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
 "futures-core",
 "futures-sink",
]

[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"

[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
 "futures-core",
 "futures-task",
 "futures-util",
]

[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"

[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"

[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"

[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
 "futures-channel",
 "futures-core",
 "futures-io",
 "futures-macro",
 "futures-sink",
 "futures-task",
 "memchr",
 "pin-project-lite",
 "pin-utils",
 "slab",
]

[[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",
 "js-sys",
 "libc",
 "r-efi",
 "wasip2",
 "wasm-bindgen",
]

[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
 "allocator-api2",
 "equivalent",
 "foldhash",
]

[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"

[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
 "cfg-if",
 "libc",
 "windows-link",
]

[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
 "bytes",
 "itoa",
]

[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
 "bytes",
 "http",
]

[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
 "bytes",
 "futures-core",
 "http",
 "http-body",
 "pin-project-lite",
]

[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"

[[package]]
name = "hyper"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
 "atomic-waker",
 "bytes",
 "futures-channel",
 "futures-core",
 "http",
 "http-body",
 "httparse",
 "itoa",
 "pin-project-lite",
 "pin-utils",
 "smallvec",
 "tokio",
 "want",
]

[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
 "http",
 "hyper",
 "hyper-util",
 "rustls",
 "rustls-pki-types",
 "tokio",
 "tokio-rustls",
 "tower-service",
 "webpki-roots",
]

[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
 "base64",
 "bytes",
 "futures-channel",
 "futures-util",
 "http",
 "http-body",
 "hyper",
 "ipnet",
 "libc",
 "percent-encoding",
 "pin-project-lite",
 "socket2",
 "tokio",
 "tower-service",
 "tracing",
]

[[package]]
name = "icu_collections"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
 "displaydoc",
 "potential_utf",
 "yoke",
 "zerofrom",
 "zerovec",
]

[[package]]
name = "icu_locale_core"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
 "displaydoc",
 "litemap",
 "tinystr",
 "writeable",
 "zerovec",
]

[[package]]
name = "icu_normalizer"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
dependencies = [
 "icu_collections",
 "icu_normalizer_data",
 "icu_properties",
 "icu_provider",
 "smallvec",
 "zerovec",
]

[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"

[[package]]
name = "icu_properties"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
dependencies = [
 "icu_collections",
 "icu_locale_core",
 "icu_properties_data",
 "icu_provider",
 "zerotrie",
 "zerovec",
]

[[package]]
name = "icu_properties_data"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"

[[package]]
name = "icu_provider"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [
 "displaydoc",
 "icu_locale_core",
 "writeable",
 "yoke",
 "zerofrom",
 "zerotrie",
 "zerovec",
]

[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"

[[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.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
dependencies = [
 "icu_normalizer",
 "icu_properties",
]

[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
 "bytemuck",
 "byteorder-lite",
 "moxcms",
 "num-traits",
]

[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
 "rustversion",
]

[[package]]
name = "instability"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
dependencies = [
 "darling",
 "indoc",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"

[[package]]
name = "iri-string"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
dependencies = [
 "memchr",
 "serde",
]

[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
 "once_cell",
]

[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
 "is-docker",
 "once_cell",
]

[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"

[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
 "either",
]

[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"

[[package]]
name = "js-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
dependencies = [
 "once_cell",
 "wasm-bindgen",
]

[[package]]
name = "libc"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"

[[package]]
name = "libredox"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
 "bitflags",
 "libc",
]

[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"

[[package]]
name = "litemap"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"

[[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.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"

[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
 "hashbrown",
]

[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"

[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"

[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
 "libc",
 "log",
 "wasi",
 "windows-sys 0.61.2",
]

[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
 "num-traits",
 "pxfm",
]

[[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.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"

[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"

[[package]]
name = "open"
version = "5.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
dependencies = [
 "is-wsl",
 "libc",
 "pathdiff",
]

[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"

[[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 = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"

[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"

[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"

[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"

[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"

[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
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 = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "pxfm"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
dependencies = [
 "num-traits",
]

[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
 "image",
]

[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
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.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
 "bytes",
 "getrandom 0.3.4",
 "lru-slab",
 "rand",
 "ring",
 "rustc-hash",
 "rustls",
 "rustls-pki-types",
 "slab",
 "thiserror",
 "tinyvec",
 "tracing",
 "web-time",
]

[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
 "cfg_aliases",
 "libc",
 "once_cell",
 "socket2",
 "tracing",
 "windows-sys 0.60.2",
]

[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
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 = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
 "rand_chacha",
 "rand_core",
]

[[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",
]

[[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 = "ratatui"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
dependencies = [
 "bitflags",
 "cassowary",
 "compact_str",
 "crossterm",
 "instability",
 "itertools",
 "lru",
 "paste",
 "strum",
 "strum_macros",
 "unicode-segmentation",
 "unicode-truncate",
 "unicode-width",
]

[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
 "bitflags",
]

[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
 "getrandom 0.2.17",
 "libredox",
 "thiserror",
]

[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
 "base64",
 "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",
 "serde",
 "serde_json",
 "serde_urlencoded",
 "sync_wrapper",
 "tokio",
 "tokio-rustls",
 "tokio-util",
 "tower",
 "tower-http",
 "tower-service",
 "url",
 "wasm-bindgen",
 "wasm-bindgen-futures",
 "wasm-streams",
 "web-sys",
 "webpki-roots",
]

[[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 = "rpassword"
version = "7.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
dependencies = [
 "libc",
 "rtoolbox",
 "windows-sys 0.59.0",
]

[[package]]
name = "rtoolbox"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f"
dependencies = [
 "libc",
 "windows-sys 0.52.0",
]

[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"

[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
 "bitflags",
 "errno",
 "libc",
 "linux-raw-sys",
 "windows-sys 0.59.0",
]

[[package]]
name = "rustls"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
 "once_cell",
 "ring",
 "rustls-pki-types",
 "rustls-webpki",
 "subtle",
 "zeroize",
]

[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
 "web-time",
 "zeroize",
]

[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
 "ring",
 "rustls-pki-types",
 "untrusted",
]

[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"

[[package]]
name = "ryu"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"

[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"

[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
 "serde_core",
 "serde_derive",
]

[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
 "serde_derive",
]

[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
 "itoa",
 "memchr",
 "serde",
 "serde_core",
 "zmij",
]

[[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 = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"

[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
 "libc",
 "signal-hook-registry",
]

[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
 "libc",
 "mio",
 "signal-hook",
]

[[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 = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"

[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"

[[package]]
name = "socket2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
dependencies = [
 "libc",
 "windows-sys 0.60.2",
]

[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"

[[package]]
name = "starbott-cli"
version = "0.1.0"
dependencies = [
 "clap",
 "crossterm",
 "dirs",
 "futures",
 "hostname",
 "open",
 "qrcode",
 "ratatui",
 "reqwest",
 "rpassword",
 "serde",
 "serde_json",
 "thiserror",
 "tokio",
 "tokio-stream",
 "unicode-width",
 "url",
]

[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"

[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"

[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
 "strum_macros",
]

[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
 "heck",
 "proc-macro2",
 "quote",
 "rustversion",
 "syn",
]

[[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.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
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.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
 "thiserror-impl",
]

[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "tinystr"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
 "displaydoc",
 "zerovec",
]

[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
 "tinyvec_macros",
]

[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"

[[package]]
name = "tokio"
version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
 "bytes",
 "libc",
 "mio",
 "pin-project-lite",
 "socket2",
 "tokio-macros",
 "windows-sys 0.61.2",
]

[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
 "rustls",
 "tokio",
]

[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
 "futures-core",
 "pin-project-lite",
 "tokio",
]

[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
 "bytes",
 "futures-core",
 "futures-sink",
 "pin-project-lite",
 "tokio",
]

[[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",
]

[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
 "bitflags",
 "bytes",
 "futures-util",
 "http",
 "http-body",
 "iri-string",
 "pin-project-lite",
 "tower",
 "tower-layer",
 "tower-service",
]

[[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 = [
 "pin-project-lite",
 "tracing-core",
]

[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
 "once_cell",
]

[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"

[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"

[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"

[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
 "itertools",
 "unicode-segmentation",
 "unicode-width",
]

[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"

[[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 = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"

[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
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.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
 "wit-bindgen",
]

[[package]]
name = "wasm-bindgen"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
dependencies = [
 "cfg-if",
 "once_cell",
 "rustversion",
 "wasm-bindgen-macro",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-futures"
version = "0.4.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
dependencies = [
 "cfg-if",
 "futures-util",
 "js-sys",
 "once_cell",
 "wasm-bindgen",
 "web-sys",
]

[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
dependencies = [
 "quote",
 "wasm-bindgen-macro-support",
]

[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
dependencies = [
 "bumpalo",
 "proc-macro2",
 "quote",
 "syn",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
 "futures-util",
 "js-sys",
 "wasm-bindgen",
 "wasm-bindgen-futures",
 "web-sys",
]

[[package]]
name = "web-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
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 = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
 "rustls-pki-types",
]

[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
 "winapi-i686-pc-windows-gnu",
 "winapi-x86_64-pc-windows-gnu",
]

[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"

[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"

[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
 "windows-targets 0.52.6",
]

[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
 "windows-targets 0.52.6",
]

[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
 "windows-targets 0.53.5",
]

[[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 0.52.6",
 "windows_aarch64_msvc 0.52.6",
 "windows_i686_gnu 0.52.6",
 "windows_i686_gnullvm 0.52.6",
 "windows_i686_msvc 0.52.6",
 "windows_x86_64_gnu 0.52.6",
 "windows_x86_64_gnullvm 0.52.6",
 "windows_x86_64_msvc 0.52.6",
]

[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
 "windows-link",
 "windows_aarch64_gnullvm 0.53.1",
 "windows_aarch64_msvc 0.53.1",
 "windows_i686_gnu 0.53.1",
 "windows_i686_gnullvm 0.53.1",
 "windows_i686_msvc 0.53.1",
 "windows_x86_64_gnu 0.53.1",
 "windows_x86_64_gnullvm 0.53.1",
 "windows_x86_64_msvc 0.53.1",
]

[[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_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"

[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"

[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"

[[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_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"

[[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_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"

[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"

[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"

[[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_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"

[[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_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"

[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"

[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"

[[package]]
name = "writeable"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"

[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
dependencies = [
 "stable_deref_trait",
 "yoke-derive",
 "zerofrom",
]

[[package]]
name = "yoke-derive"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
 "zerocopy-derive",
]

[[package]]
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "zerofrom"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
dependencies = [
 "zerofrom-derive",
]

[[package]]
name = "zerofrom-derive"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"

[[package]]
name = "zerotrie"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
dependencies = [
 "displaydoc",
 "yoke",
 "zerofrom",
]

[[package]]
name = "zerovec"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
 "yoke",
 "zerofrom",
 "zerovec-derive",
]

[[package]]
name = "zerovec-derive"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "zmij"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"

remove_duplicates.py

Path: starbot_tui/cli/remove_duplicates.py

Size: 2741 bytes

#!/usr/bin/env python3
"""
Remove duplicate handler functions from tui.rs
"""

def remove_function(lines, start_line, func_name):
    """Remove a function from lines, finding the matching closing brace."""
    # start_line is 0-indexed
    if start_line >= len(lines):
        return lines

    # Count braces to find function end
    brace_count = 0
    i = start_line
    started = False

    while i < len(lines):
        line = lines[i]
        for char in line:
            if char == '{':
                brace_count += 1
                started = True
            elif char == '}':
                brace_count -= 1
                if started and brace_count == 0:
                    # Found closing brace, delete from start_line to i (inclusive)
                    print(f"  Removing {func_name}: lines {start_line+1}-{i+1} ({i-start_line+1} lines)")
                    return lines[:start_line] + lines[i+1:]
        i += 1

    print(f"  WARNING: Could not find closing brace for {func_name}")
    return lines

# Read file
with open('src/commands/tui.rs', 'r') as f:
    lines = f.readlines()

print(f"Original file: {len(lines)} lines")

# Functions to remove (name, approximate line number - 1-indexed)
# Working from bottom to top to maintain line numbers
functions_to_remove = [
    ('extract_usage_line', 3389),
    ('extract_provider_model', 3363),
    ('extract_reply', 3352),
    ('send_chat_text', 1543),
    ('retry_last_chat', 1512),
    ('apply_workspace_selection', 1475),
    ('move_selection_wrap', 1459),
    ('move_selection', 1443),
    ('handle_key', 1020),
    ('handle_event', 1005),
    ('handle_tui_msg', 532),
    ('build_chat_body', 505),
    ('spawn_chat_request', 490),
    ('spawn_tool_propose', 467),
    ('spawn_workspaces_fetch', 460),
    ('spawn_health_fetch', 453),
    ('spawn_models_fetch', 446),
]

# Sort by line number descending (remove from bottom to top)
functions_to_remove.sort(key=lambda x: x[1], reverse=True)

for func_name, line_num in functions_to_remove:
    # Find the function in the file (search nearby in case line numbers shifted)
    found = False
    for offset in range(-5, 10):
        check_line = line_num - 1 + offset  # Convert to 0-indexed
        if check_line < 0 or check_line >= len(lines):
            continue
        if f'fn {func_name}(' in lines[check_line]:
            lines = remove_function(lines, check_line, func_name)
            found = True
            break

    if not found:
        print(f"  WARNING: Could not find {func_name} near line {line_num}")

# Write back
with open('src/commands/tui.rs', 'w') as f:
    f.writelines(lines)

print(f"\n✅ Done! New file: {len(lines)} lines")
print(f"   Removed: {len(functions_to_remove)} functions")

.gitignore

Path: starbot_tui/cli/.gitignore

Size: 8 bytes

target/

Cargo.toml

Path: starbot_tui/cli/Cargo.toml

Size: 676 bytes

[package]
name = "starbott-cli"
version = "0.1.0"
edition = "2024"

[[bin]]
name = "starbott"
path = "src/main.rs"

[dependencies]
clap = { version = "4.5.31", features = ["derive"] }
serde = { version = "1.0.218", features = ["derive"] }
serde_json = "1.0.139"
reqwest = { version = "0.12.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread", "time", "sync"] }
tokio-stream = "0.1"
futures = "0.3"
thiserror = "2.0.11"
dirs = "6.0.0"
url = "2.5.4"
rpassword = "7.3.1"
open = "5.3.2"
ratatui = "0.28.1"
crossterm = "0.28.1"
unicode-width = "0.1.14"
qrcode = "0.14"
hostname = "0.4"

README.md

Path: starbot_tui/cli/README.md

Size: 1837 bytes

# starbott (Rust CLI)

Terminal client for Starbott backend APIs.

## Build

```bash
cd cli
cargo build --release
```

Binary output:

```bash
./target/release/starbott
```

## Config

Config file path:

- macOS/Linux: `~/.config/starbott/config.json`
- Windows: `%APPDATA%\\starbott\\config.json`

Initialize:

```bash
starbott config init --api-url http://localhost:3003
```

Set token:

```bash
starbott auth login --token "<jwt>"
```

## Commands

- `starbott config init|get|set|profiles|use`
- `starbott auth login|logout`
- `starbott workspaces create|list|permissions`
- `starbott tools propose|commit|deny|runs`
- `starbott whoami`
- `starbott chat "<prompt>" [--stdin] [-m|--model <selector>] [--stream]`
- `starbott tui [-m|--model <selector>]`
- `starbott usage [--since <value>] [--until <value>] [--group day|model|provider]`
- `starbott billing status`
- `starbott billing portal [--open]`
- `starbott health`

## TUI

Fullscreen chat UI for quick testing.

```bash
starbott tui
starbott tui -m vertex:gemini-3-flash-preview
```

Cute mode toggle (SPEC5):

Create `~/.starbott/config` with:

```ini
cute = on        # phrases + icons + spinner
# cute = minimal # icons/spinner only
# cute = off     # plain status text (no cute phrases)
```

Keys:

- `Enter` send
- `F2` or `Ctrl+M` model picker
- `PgUp/PgDn` scroll
- `Ctrl+R` reload `/v1/models`
- `Ctrl+D` toggle debug panel
- `Esc` quit

Convenience (dev) wrapper that logs in and runs the CLI:

```bash
./scripts/starbott-dev.sh tui
```

## Global flags

- `--profile <name>`
- `--api-url <url>`
- `--json`
- `--quiet`
- `--timeout <ms>` (default `30000`)
- `--retries <n>` (default `2`)
- `--verbose`
- `--debug`

## Exit codes

- `0` success
- `1` generic error
- `2` auth error
- `3` usage error
- `4` network error / timeout
- `5` rate limited
- `6` server error

Cargo.lock

Path: starbot_tui/cli/Cargo.lock

Size: 55403 bytes

# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"

[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
 "anstyle",
 "anstyle-parse",
 "anstyle-query",
 "anstyle-wincon",
 "colorchoice",
 "is_terminal_polyfill",
 "utf8parse",
]

[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"

[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
 "utf8parse",
]

[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
 "windows-sys 0.61.2",
]

[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
 "anstyle",
 "once_cell_polyfill",
 "windows-sys 0.61.2",
]

[[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.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"

[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"

[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"

[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"

[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"

[[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.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"

[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"

[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
 "rustversion",
]

[[package]]
name = "cc"
version = "1.2.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
dependencies = [
 "find-msvc-tools",
 "shlex",
]

[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"

[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"

[[package]]
name = "clap"
version = "4.5.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
dependencies = [
 "clap_builder",
 "clap_derive",
]

[[package]]
name = "clap_builder"
version = "4.5.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
dependencies = [
 "anstream",
 "anstyle",
 "clap_lex",
 "strsim",
]

[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
 "heck",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "clap_lex"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"

[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"

[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
 "castaway",
 "cfg-if",
 "itoa",
 "rustversion",
 "ryu",
 "static_assertions",
]

[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
 "bitflags",
 "crossterm_winapi",
 "mio",
 "parking_lot",
 "rustix",
 "signal-hook",
 "signal-hook-mio",
 "winapi",
]

[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
 "winapi",
]

[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
 "darling_core",
 "darling_macro",
]

[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
 "ident_case",
 "proc-macro2",
 "quote",
 "strsim",
 "syn",
]

[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
 "darling_core",
 "quote",
 "syn",
]

[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
 "dirs-sys",
]

[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
 "libc",
 "option-ext",
 "redox_users",
 "windows-sys 0.61.2",
]

[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"

[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"

[[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 = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"

[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"

[[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 = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
 "futures-channel",
 "futures-core",
 "futures-executor",
 "futures-io",
 "futures-sink",
 "futures-task",
 "futures-util",
]

[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
 "futures-core",
 "futures-sink",
]

[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"

[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
 "futures-core",
 "futures-task",
 "futures-util",
]

[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"

[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"

[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"

[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
 "futures-channel",
 "futures-core",
 "futures-io",
 "futures-macro",
 "futures-sink",
 "futures-task",
 "memchr",
 "pin-project-lite",
 "pin-utils",
 "slab",
]

[[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",
 "js-sys",
 "libc",
 "r-efi",
 "wasip2",
 "wasm-bindgen",
]

[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
 "allocator-api2",
 "equivalent",
 "foldhash",
]

[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"

[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
 "cfg-if",
 "libc",
 "windows-link",
]

[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
 "bytes",
 "itoa",
]

[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
 "bytes",
 "http",
]

[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
 "bytes",
 "futures-core",
 "http",
 "http-body",
 "pin-project-lite",
]

[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"

[[package]]
name = "hyper"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
 "atomic-waker",
 "bytes",
 "futures-channel",
 "futures-core",
 "http",
 "http-body",
 "httparse",
 "itoa",
 "pin-project-lite",
 "pin-utils",
 "smallvec",
 "tokio",
 "want",
]

[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
 "http",
 "hyper",
 "hyper-util",
 "rustls",
 "rustls-pki-types",
 "tokio",
 "tokio-rustls",
 "tower-service",
 "webpki-roots",
]

[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
 "base64",
 "bytes",
 "futures-channel",
 "futures-util",
 "http",
 "http-body",
 "hyper",
 "ipnet",
 "libc",
 "percent-encoding",
 "pin-project-lite",
 "socket2",
 "tokio",
 "tower-service",
 "tracing",
]

[[package]]
name = "icu_collections"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
 "displaydoc",
 "potential_utf",
 "yoke",
 "zerofrom",
 "zerovec",
]

[[package]]
name = "icu_locale_core"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
 "displaydoc",
 "litemap",
 "tinystr",
 "writeable",
 "zerovec",
]

[[package]]
name = "icu_normalizer"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
dependencies = [
 "icu_collections",
 "icu_normalizer_data",
 "icu_properties",
 "icu_provider",
 "smallvec",
 "zerovec",
]

[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"

[[package]]
name = "icu_properties"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
dependencies = [
 "icu_collections",
 "icu_locale_core",
 "icu_properties_data",
 "icu_provider",
 "zerotrie",
 "zerovec",
]

[[package]]
name = "icu_properties_data"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"

[[package]]
name = "icu_provider"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [
 "displaydoc",
 "icu_locale_core",
 "writeable",
 "yoke",
 "zerofrom",
 "zerotrie",
 "zerovec",
]

[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"

[[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.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
dependencies = [
 "icu_normalizer",
 "icu_properties",
]

[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
 "bytemuck",
 "byteorder-lite",
 "moxcms",
 "num-traits",
]

[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
 "rustversion",
]

[[package]]
name = "instability"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
dependencies = [
 "darling",
 "indoc",
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"

[[package]]
name = "iri-string"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
dependencies = [
 "memchr",
 "serde",
]

[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
 "once_cell",
]

[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
 "is-docker",
 "once_cell",
]

[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"

[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
 "either",
]

[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"

[[package]]
name = "js-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
dependencies = [
 "once_cell",
 "wasm-bindgen",
]

[[package]]
name = "libc"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"

[[package]]
name = "libredox"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
 "bitflags",
 "libc",
]

[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"

[[package]]
name = "litemap"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"

[[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.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"

[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
 "hashbrown",
]

[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"

[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"

[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
 "libc",
 "log",
 "wasi",
 "windows-sys 0.61.2",
]

[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
 "num-traits",
 "pxfm",
]

[[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.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"

[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"

[[package]]
name = "open"
version = "5.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
dependencies = [
 "is-wsl",
 "libc",
 "pathdiff",
]

[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"

[[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 = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"

[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"

[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"

[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"

[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"

[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
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 = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "pxfm"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
dependencies = [
 "num-traits",
]

[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
 "image",
]

[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
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.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
 "bytes",
 "getrandom 0.3.4",
 "lru-slab",
 "rand",
 "ring",
 "rustc-hash",
 "rustls",
 "rustls-pki-types",
 "slab",
 "thiserror",
 "tinyvec",
 "tracing",
 "web-time",
]

[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
 "cfg_aliases",
 "libc",
 "once_cell",
 "socket2",
 "tracing",
 "windows-sys 0.60.2",
]

[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
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 = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
 "rand_chacha",
 "rand_core",
]

[[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",
]

[[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 = "ratatui"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
dependencies = [
 "bitflags",
 "cassowary",
 "compact_str",
 "crossterm",
 "instability",
 "itertools",
 "lru",
 "paste",
 "strum",
 "strum_macros",
 "unicode-segmentation",
 "unicode-truncate",
 "unicode-width",
]

[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
 "bitflags",
]

[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
 "getrandom 0.2.17",
 "libredox",
 "thiserror",
]

[[package]]
name = "reqwest"
version = "0.12.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
 "base64",
 "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",
 "serde",
 "serde_json",
 "serde_urlencoded",
 "sync_wrapper",
 "tokio",
 "tokio-rustls",
 "tokio-util",
 "tower",
 "tower-http",
 "tower-service",
 "url",
 "wasm-bindgen",
 "wasm-bindgen-futures",
 "wasm-streams",
 "web-sys",
 "webpki-roots",
]

[[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 = "rpassword"
version = "7.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39"
dependencies = [
 "libc",
 "rtoolbox",
 "windows-sys 0.59.0",
]

[[package]]
name = "rtoolbox"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f"
dependencies = [
 "libc",
 "windows-sys 0.52.0",
]

[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"

[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
 "bitflags",
 "errno",
 "libc",
 "linux-raw-sys",
 "windows-sys 0.59.0",
]

[[package]]
name = "rustls"
version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
 "once_cell",
 "ring",
 "rustls-pki-types",
 "rustls-webpki",
 "subtle",
 "zeroize",
]

[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
 "web-time",
 "zeroize",
]

[[package]]
name = "rustls-webpki"
version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
 "ring",
 "rustls-pki-types",
 "untrusted",
]

[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"

[[package]]
name = "ryu"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"

[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"

[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
 "serde_core",
 "serde_derive",
]

[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
 "serde_derive",
]

[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
 "itoa",
 "memchr",
 "serde",
 "serde_core",
 "zmij",
]

[[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 = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"

[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
 "libc",
 "signal-hook-registry",
]

[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
 "libc",
 "mio",
 "signal-hook",
]

[[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 = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"

[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"

[[package]]
name = "socket2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
dependencies = [
 "libc",
 "windows-sys 0.60.2",
]

[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"

[[package]]
name = "starbott-cli"
version = "0.1.0"
dependencies = [
 "clap",
 "crossterm",
 "dirs",
 "futures",
 "hostname",
 "open",
 "qrcode",
 "ratatui",
 "reqwest",
 "rpassword",
 "serde",
 "serde_json",
 "thiserror",
 "tokio",
 "tokio-stream",
 "unicode-width",
 "url",
]

[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"

[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"

[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
 "strum_macros",
]

[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
 "heck",
 "proc-macro2",
 "quote",
 "rustversion",
 "syn",
]

[[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.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
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.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
 "thiserror-impl",
]

[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "tinystr"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
 "displaydoc",
 "zerovec",
]

[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
 "tinyvec_macros",
]

[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"

[[package]]
name = "tokio"
version = "1.49.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
dependencies = [
 "bytes",
 "libc",
 "mio",
 "pin-project-lite",
 "socket2",
 "tokio-macros",
 "windows-sys 0.61.2",
]

[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
 "rustls",
 "tokio",
]

[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
 "futures-core",
 "pin-project-lite",
 "tokio",
]

[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
 "bytes",
 "futures-core",
 "futures-sink",
 "pin-project-lite",
 "tokio",
]

[[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",
]

[[package]]
name = "tower-http"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
dependencies = [
 "bitflags",
 "bytes",
 "futures-util",
 "http",
 "http-body",
 "iri-string",
 "pin-project-lite",
 "tower",
 "tower-layer",
 "tower-service",
]

[[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 = [
 "pin-project-lite",
 "tracing-core",
]

[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
 "once_cell",
]

[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"

[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"

[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"

[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
 "itertools",
 "unicode-segmentation",
 "unicode-width",
]

[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"

[[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 = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"

[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
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.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
 "wit-bindgen",
]

[[package]]
name = "wasm-bindgen"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
dependencies = [
 "cfg-if",
 "once_cell",
 "rustversion",
 "wasm-bindgen-macro",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-futures"
version = "0.4.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
dependencies = [
 "cfg-if",
 "futures-util",
 "js-sys",
 "once_cell",
 "wasm-bindgen",
 "web-sys",
]

[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
dependencies = [
 "quote",
 "wasm-bindgen-macro-support",
]

[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
dependencies = [
 "bumpalo",
 "proc-macro2",
 "quote",
 "syn",
 "wasm-bindgen-shared",
]

[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
dependencies = [
 "unicode-ident",
]

[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
 "futures-util",
 "js-sys",
 "wasm-bindgen",
 "wasm-bindgen-futures",
 "web-sys",
]

[[package]]
name = "web-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
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 = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
 "rustls-pki-types",
]

[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
 "winapi-i686-pc-windows-gnu",
 "winapi-x86_64-pc-windows-gnu",
]

[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"

[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"

[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"

[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
 "windows-targets 0.52.6",
]

[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
 "windows-targets 0.52.6",
]

[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
 "windows-targets 0.53.5",
]

[[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 0.52.6",
 "windows_aarch64_msvc 0.52.6",
 "windows_i686_gnu 0.52.6",
 "windows_i686_gnullvm 0.52.6",
 "windows_i686_msvc 0.52.6",
 "windows_x86_64_gnu 0.52.6",
 "windows_x86_64_gnullvm 0.52.6",
 "windows_x86_64_msvc 0.52.6",
]

[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
 "windows-link",
 "windows_aarch64_gnullvm 0.53.1",
 "windows_aarch64_msvc 0.53.1",
 "windows_i686_gnu 0.53.1",
 "windows_i686_gnullvm 0.53.1",
 "windows_i686_msvc 0.53.1",
 "windows_x86_64_gnu 0.53.1",
 "windows_x86_64_gnullvm 0.53.1",
 "windows_x86_64_msvc 0.53.1",
]

[[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_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"

[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"

[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"

[[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_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"

[[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_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"

[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"

[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"

[[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_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"

[[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_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"

[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"

[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"

[[package]]
name = "writeable"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"

[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
dependencies = [
 "stable_deref_trait",
 "yoke-derive",
 "zerofrom",
]

[[package]]
name = "yoke-derive"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
 "zerocopy-derive",
]

[[package]]
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "zerofrom"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
dependencies = [
 "zerofrom-derive",
]

[[package]]
name = "zerofrom-derive"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
 "synstructure",
]

[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"

[[package]]
name = "zerotrie"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
dependencies = [
 "displaydoc",
 "yoke",
 "zerofrom",
]

[[package]]
name = "zerovec"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
 "yoke",
 "zerofrom",
 "zerovec-derive",
]

[[package]]
name = "zerovec-derive"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
 "proc-macro2",
 "quote",
 "syn",
]

[[package]]
name = "zmij"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"

smoke.sh

Path: starbot_tui/cli/scripts/smoke.sh

Size: 192 bytes

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"

"${SCRIPT_DIR}/starbott-dev.sh" whoami
echo
"${SCRIPT_DIR}/starbott-dev.sh" chat "hi"

starbott-dev.sh

Path: starbot_tui/cli/scripts/starbott-dev.sh

Size: 1404 bytes

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CLI_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"

API_URL="${STARBOTT_API_URL:-http://127.0.0.1:3003}"

need_cmd() {
  command -v "$1" >/dev/null 2>&1 || {
    echo "Missing required command: $1" >&2
    exit 3
  }
}

need_cmd curl

BIN="${CLI_DIR}/target/debug/starbott"
if [[ ! -x "$BIN" ]]; then
  # Prefer offline build (this environment often blocks crates.io).
  (cd "$CLI_DIR" && cargo build --offline >/dev/null) || (cd "$CLI_DIR" && cargo build >/dev/null)
fi

# Sanity check: make sure API is reachable before launching a TUI.
if ! curl -fsS --max-time 15 "${API_URL}/health" >/dev/null 2>&1; then
  echo "API not reachable at ${API_URL}." >&2
  echo "Start the canonical STARBOT API, then re-run this script." >&2
  exit 2
fi

# Dev-only shortcut: allow a direct token override via STARBOTT_TOKEN.
if [[ -z "${STARBOTT_TOKEN:-}" ]]; then
  if [[ -n "${CI:-}" ]]; then
    echo "Not authenticated (CI mode). Set STARBOTT_TOKEN or run: ${BIN} --api-url ${API_URL} auth login" >&2
    exit 2
  fi

  # If the user already has a saved token in their CLI profile, whoami will succeed.
  if ! "$BIN" --api-url "$API_URL" whoami >/dev/null 2>&1; then
    echo "Not authenticated. Starting device login..." >&2
    "$BIN" --api-url "$API_URL" auth login
  fi
fi

exec "$BIN" --api-url "$API_URL" "$@"

output.rs

Path: starbot_tui/cli/src/output.rs

Size: 1300 bytes

use serde::Serialize;

use crate::errors::CliError;

#[derive(Debug, Clone)]
pub struct OutputMode {
    pub json: bool,
    pub quiet: bool,
    pub verbose: bool,
    pub debug: bool,
}

impl OutputMode {
    pub fn print_json<T: Serialize>(&self, value: &T) -> Result<(), CliError> {
        let text = serde_json::to_string(value)?;
        println!("{text}");
        Ok(())
    }

    pub fn print_human(&self, message: &str) {
        if self.json || self.quiet {
            return;
        }
        println!("{message}");
    }

    pub fn print_stderr(&self, message: &str) {
        if self.json || self.quiet {
            return;
        }
        eprintln!("{message}");
    }

    pub fn print_verbose(&self, message: &str) {
        if !self.verbose || self.json || self.quiet {
            return;
        }
        eprintln!("{message}");
    }
}

pub fn print_error(error: &CliError, mode: &OutputMode) {
    if mode.json {
        let payload = serde_json::json!({
            "error": error.to_string(),
            "code": error.exit_code()
        });
        println!(
            "{}",
            serde_json::to_string(&payload)
                .unwrap_or_else(|_| "{\"error\":\"unknown\"}".to_string())
        );
        return;
    }

    eprintln!("Error: {error}");
}

api.rs

Path: starbot_tui/cli/src/api.rs

Size: 10412 bytes

use std::time::{Duration, Instant};

use reqwest::{Client, Method, StatusCode};
use serde_json::{Value, json};
use tokio::time::sleep;
use tokio::sync::mpsc;
use futures::StreamExt;

use crate::errors::{CliError, redact_secret, with_debug_hint};

#[derive(Debug, Clone)]
pub struct ApiClient {
    client: Client,
    base_url: String,
    token: Option<String>,
    retries: u32,
    debug: bool,
}

#[derive(Debug, Clone)]
pub struct ApiResponse {
    pub request_id: Option<String>,
    pub elapsed_ms: u128,
    pub json: Value,
}

impl ApiClient {
    pub fn new(
        base_url: String,
        token: Option<String>,
        timeout_ms: u64,
        retries: u32,
        debug: bool,
    ) -> Result<Self, CliError> {
        let timeout = Duration::from_millis(timeout_ms.max(1));
        let client = Client::builder().timeout(timeout).build()?;
        Ok(Self {
            client,
            base_url,
            token,
            retries,
            debug,
        })
    }

    pub async fn get_json(
        &self,
        path: &str,
        query: Option<&[(String, String)]>,
        auth_required: bool,
    ) -> Result<ApiResponse, CliError> {
        self.request_json(Method::GET, path, query, None, auth_required, true)
            .await
    }

    pub async fn post_json(
        &self,
        path: &str,
        body: Option<Value>,
        auth_required: bool,
    ) -> Result<ApiResponse, CliError> {
        self.request_json(Method::POST, path, None, body, auth_required, false)
            .await
    }

    pub async fn post_json_with_body(
        &self,
        path: &str,
        body: &Value,
        auth_required: bool,
    ) -> Result<ApiResponse, CliError> {
        self.request_json(Method::POST, path, None, Some(body.clone()), auth_required, false)
            .await
    }

    pub async fn request_json(
        &self,
        method: Method,
        path: &str,
        query: Option<&[(String, String)]>,
        body: Option<Value>,
        auth_required: bool,
        idempotent: bool,
    ) -> Result<ApiResponse, CliError> {
        let token = if auth_required {
            Some(self.token.clone().ok_or_else(|| {
                CliError::Auth("Missing token. Run `starbott auth login` first.".to_string())
            })?)
        } else {
            self.token.clone()
        };

        let url = join_url(&self.base_url, path);
        let max_attempts = if idempotent {
            self.retries.saturating_add(1)
        } else {
            1
        };

        for attempt in 0..max_attempts {
            let started = Instant::now();
            let mut request = self.client.request(method.clone(), url.clone());

            if let Some(query_items) = query {
                request = request.query(query_items);
            }

            if let Some(ref bearer) = token {
                request = request.bearer_auth(bearer);
            }

            if let Some(ref payload) = body {
                request = request.json(payload);
            }

            let response = request.send().await;
            match response {
                Ok(resp) => {
                    let status = resp.status();
                    let request_id = resp
                        .headers()
                        .get("x-request-id")
                        .and_then(|v| v.to_str().ok())
                        .map(|s| s.to_string());

                    let text = resp.text().await.unwrap_or_default();
                    if is_retryable_status(status) && idempotent && attempt + 1 < max_attempts {
                        sleep(backoff_delay_ms(attempt)).await;
                        continue;
                    }

                    let parsed = if text.trim().is_empty() {
                        json!({})
                    } else {
                        serde_json::from_str::<Value>(&text)
                            .unwrap_or_else(|_| json!({ "raw": text }))
                    };

                    if status.is_success() {
                        return Ok(ApiResponse {
                            request_id,
                            elapsed_ms: started.elapsed().as_millis(),
                            json: parsed,
                        });
                    }

                    return Err(self.http_error(status, request_id, parsed));
                }
                Err(err) => {
                    let transient = err.is_timeout() || err.is_connect() || err.is_request();
                    if transient && idempotent && attempt + 1 < max_attempts {
                        sleep(backoff_delay_ms(attempt)).await;
                        continue;
                    }

                    let message = if err.is_timeout() {
                        "Request timed out.".to_string()
                    } else {
                        format!("Network request failed: {err}")
                    };
                    return Err(CliError::Network(with_debug_hint(&message, self.debug)));
                }
            }
        }

        Err(CliError::Network(with_debug_hint(
            "Request failed after retries.",
            self.debug,
        )))
    }

    fn http_error(
        &self,
        status: StatusCode,
        request_id: Option<String>,
        payload: Value,
    ) -> CliError {
        let message = payload
            .get("error")
            .and_then(|v| v.as_str())
            .or_else(|| payload.get("message").and_then(|v| v.as_str()))
            .map(|s| s.to_string())
            .unwrap_or_else(|| format!("Request failed with status {}", status.as_u16()));

        let mut details = message;
        if let Some(id) = request_id {
            details.push_str(&format!(" (request_id: {id})"));
        }
        if self.debug {
            let mut payload_text = payload.to_string();
            if let Some(token) = &self.token {
                payload_text = payload_text.replace(token, &redact_secret(token));
            }
            details.push_str(&format!(" payload={payload_text}"));
        } else {
            details = with_debug_hint(&details, false);
        }

        match status.as_u16() {
            400 => CliError::Usage(details),
            401 | 403 => CliError::Auth(details),
            429 => CliError::RateLimited(details),
            500..=599 => CliError::Server(details),
            _ => CliError::Generic(details),
        }
    }

    /// Start a streaming POST request (SSE format)
    /// Returns a receiver channel that yields (event_type, data) tuples
    pub async fn post_stream(
        &self,
        path: &str,
        body: Option<Value>,
        auth_required: bool,
    ) -> Result<mpsc::UnboundedReceiver<StreamEvent>, CliError> {
        let token = if auth_required {
            Some(self.token.clone().ok_or_else(|| {
                CliError::Auth("Missing token. Run `starbott auth login` first.".to_string())
            })?)
        } else {
            self.token.clone()
        };

        let url = join_url(&self.base_url, path);
        let mut request = self.client.post(url);

        if let Some(token_str) = token {
            request = request.header("Authorization", format!("Bearer {}", token_str));
        }

        if let Some(body_val) = body {
            request = request.json(&body_val);
        }

        let response = request.send().await.map_err(|e| {
            CliError::Network(format!("Stream request failed: {}", e))
        })?;

        if !response.status().is_success() {
            let status = response.status();
            let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
            return Err(self.http_error(status, None, payload));
        }

        let (tx, rx) = mpsc::unbounded_channel();

        // Spawn a task to process the stream
        tokio::spawn(async move {
            let mut stream = response.bytes_stream();
            let mut buffer = String::new();
            let mut current_event = String::new();

            while let Some(chunk_result) = stream.next().await {
                match chunk_result {
                    Ok(chunk) => {
                        if let Ok(text) = std::str::from_utf8(&chunk) {
                            buffer.push_str(text);

                            // Process complete lines
                            while let Some(newline_pos) = buffer.find('\n') {
                                let line = buffer[..newline_pos].trim().to_string();
                                buffer = buffer[newline_pos + 1..].to_string();

                                if line.is_empty() {
                                    // Empty line signals end of event
                                    current_event.clear();
                                } else if let Some(event_type) = line.strip_prefix("event: ") {
                                    current_event = event_type.to_string();
                                } else if let Some(data) = line.strip_prefix("data: ") {
                                    if !current_event.is_empty() {
                                        let _ = tx.send(StreamEvent {
                                            event_type: current_event.clone(),
                                            data: data.to_string(),
                                        });
                                    }
                                }
                            }
                        }
                    }
                    Err(_) => break,
                }
            }
        });

        Ok(rx)
    }
}

#[derive(Debug, Clone)]
pub struct StreamEvent {
    pub event_type: String,
    pub data: String,
}

fn join_url(base_url: &str, path: &str) -> String {
    if path.starts_with("http://") || path.starts_with("https://") {
        return path.to_string();
    }
    format!(
        "{}/{}",
        base_url.trim_end_matches('/'),
        path.trim_start_matches('/')
    )
}

fn is_retryable_status(status: StatusCode) -> bool {
    matches!(
        status,
        StatusCode::TOO_MANY_REQUESTS
            | StatusCode::BAD_GATEWAY
            | StatusCode::SERVICE_UNAVAILABLE
            | StatusCode::GATEWAY_TIMEOUT
    )
}

fn backoff_delay_ms(attempt: u32) -> Duration {
    let pow = attempt.min(6);
    let factor = 1u64 << pow;
    Duration::from_millis(200 * factor)
}

app.rs

Path: starbot_tui/cli/src/app.rs

Size: 1203 bytes

use std::path::PathBuf;

use crate::api::ApiClient;
use crate::config::{CliConfig, active_profile_name, resolve_api_url, resolve_token};
use crate::errors::CliError;
use crate::output::OutputMode;

#[derive(Debug, Clone)]
pub struct Runtime {
    pub output: OutputMode,
    pub config: CliConfig,
    pub config_path: PathBuf,
    pub profile_override: Option<String>,
    pub api_url_override: Option<String>,
    pub timeout_ms: u64,
    pub retries: u32,
}

impl Runtime {
    pub fn active_profile(&self) -> String {
        active_profile_name(&self.config, self.profile_override.as_deref())
    }

    pub fn resolved_api_url(&self) -> Result<String, CliError> {
        resolve_api_url(
            &self.config,
            &self.active_profile(),
            self.api_url_override.as_deref(),
        )
    }

    pub fn resolved_token(&self) -> Option<String> {
        resolve_token(&self.config, &self.active_profile())
    }

    pub fn api_client(&self) -> Result<ApiClient, CliError> {
        ApiClient::new(
            self.resolved_api_url()?,
            self.resolved_token(),
            self.timeout_ms,
            self.retries,
            self.output.debug,
        )
    }
}

config.rs

Path: starbot_tui/cli/src/config.rs

Size: 3963 bytes

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use url::Url;

use crate::errors::CliError;

pub const DEFAULT_API_URL: &str = "http://localhost:3003";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileConfig {
    pub api_url: String,
    pub token: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub refresh_token: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workspace_id: Option<String>,
}

impl Default for ProfileConfig {
    fn default() -> Self {
        Self {
            api_url: DEFAULT_API_URL.to_string(),
            token: None,
            refresh_token: None,
            workspace_id: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliConfig {
    pub profile: String,
    pub profiles: HashMap<String, ProfileConfig>,
}

impl Default for CliConfig {
    fn default() -> Self {
        let mut profiles = HashMap::new();
        profiles.insert("default".to_string(), ProfileConfig::default());
        Self {
            profile: "default".to_string(),
            profiles,
        }
    }
}

pub fn config_path() -> Result<PathBuf, CliError> {
    let base = dirs::config_dir().ok_or_else(|| {
        CliError::Generic("Could not resolve config directory for this OS.".to_string())
    })?;
    Ok(base.join("starbott").join("config.json"))
}

pub fn load_config() -> Result<CliConfig, CliError> {
    let path = config_path()?;
    if !path.exists() {
        return Ok(CliConfig::default());
    }

    let text = fs::read_to_string(&path)?;
    let mut config: CliConfig = serde_json::from_str(&text)?;
    let profile = config.profile.clone();
    ensure_profile(&mut config, &profile);
    Ok(config)
}

pub fn save_config(config: &CliConfig) -> Result<PathBuf, CliError> {
    let path = config_path()?;
    let parent = path
        .parent()
        .ok_or_else(|| CliError::Generic("Invalid config path.".to_string()))?;
    fs::create_dir_all(parent)?;
    fs::write(&path, serde_json::to_string_pretty(config)?)?;
    Ok(path)
}

pub fn active_profile_name(config: &CliConfig, profile_override: Option<&str>) -> String {
    profile_override
        .map(|s| s.to_string())
        .unwrap_or_else(|| config.profile.clone())
}

pub fn ensure_profile(config: &mut CliConfig, profile_name: &str) {
    if !config.profiles.contains_key(profile_name) {
        config
            .profiles
            .insert(profile_name.to_string(), ProfileConfig::default());
    }
}

pub fn profile_ref<'a>(config: &'a CliConfig, profile_name: &str) -> Option<&'a ProfileConfig> {
    config.profiles.get(profile_name)
}

pub fn profile_mut<'a>(
    config: &'a mut CliConfig,
    profile_name: &str,
) -> Option<&'a mut ProfileConfig> {
    config.profiles.get_mut(profile_name)
}

pub fn resolve_api_url(
    config: &CliConfig,
    profile_name: &str,
    api_override: Option<&str>,
) -> Result<String, CliError> {
    if let Some(url) = api_override {
        validate_url(url)?;
        return Ok(url.to_string());
    }

    let profile = profile_ref(config, profile_name)
        .ok_or_else(|| CliError::Usage(format!("Profile '{profile_name}' does not exist.")))?;
    validate_url(&profile.api_url)?;
    Ok(profile.api_url.clone())
}

pub fn resolve_token(config: &CliConfig, profile_name: &str) -> Option<String> {
    if let Ok(token) = std::env::var("STARBOTT_TOKEN") {
        if !token.trim().is_empty() {
            return Some(token.trim().to_string());
        }
    }

    profile_ref(config, profile_name).and_then(|p| p.token.clone())
}

pub fn validate_url(value: &str) -> Result<(), CliError> {
    let parsed = Url::parse(value)?;
    if parsed.scheme() != "http" && parsed.scheme() != "https" {
        return Err(CliError::Usage(
            "API URL must use http:// or https://.".to_string(),
        ));
    }
    Ok(())
}

main.rs

Path: starbot_tui/cli/src/main.rs

Size: 3485 bytes

mod api;
mod app;
mod commands;
mod config;
mod cute;
mod errors;
mod output;
mod parse;
mod tui;

use clap::{Parser, Subcommand};

use crate::app::Runtime;
use crate::commands::auth::AuthCommand;
use crate::commands::billing::BillingCommand;
use crate::commands::chat::ChatArgs;
use crate::commands::config::ConfigCommand;
use crate::commands::tools::ToolsCommand;
use crate::commands::tui::TuiArgs;
use crate::commands::usage::UsageArgs;
use crate::errors::CliError;
use crate::output::{OutputMode, print_error};
use crate::commands::workspaces::WorkspaceCommand;

#[derive(Debug, Parser)]
#[command(
    name = "starbott",
    version,
    about = "Starbot CLI for chat, usage, billing, and account operations."
)]
struct Cli {
    #[arg(long, global = true)]
    profile: Option<String>,
    #[arg(long = "api-url", global = true)]
    api_url: Option<String>,
    #[arg(long, global = true)]
    json: bool,
    #[arg(long, global = true)]
    quiet: bool,
    #[arg(long, global = true, default_value_t = 30_000)]
    timeout: u64,
    #[arg(long, global = true, default_value_t = 2)]
    retries: u32,
    #[arg(long, global = true)]
    verbose: bool,
    #[arg(long, global = true)]
    debug: bool,
    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    Config {
        #[command(subcommand)]
        command: ConfigCommand,
    },
    Auth {
        #[command(subcommand)]
        command: AuthCommand,
    },
    Workspaces {
        #[command(subcommand)]
        command: WorkspaceCommand,
    },
    Tools {
        #[command(subcommand)]
        command: ToolsCommand,
    },
    Whoami,
    Chat(ChatArgs),
    Tui(TuiArgs),
    Usage(UsageArgs),
    Billing {
        #[command(subcommand)]
        command: BillingCommand,
    },
    Health,
}

#[tokio::main]
async fn main() {
    let cli = Cli::parse();
    let output = OutputMode {
        json: cli.json,
        quiet: cli.quiet,
        verbose: cli.verbose,
        debug: cli.debug,
    };

    cute::print_banner(&output);

    let result = run(cli, output.clone()).await;
    if let Err(err) = result {
        print_error(&err, &output);
        std::process::exit(err.exit_code());
    }
}

async fn run(cli: Cli, output: OutputMode) -> Result<(), CliError> {
    let config = config::load_config()?;
    let config_path = config::config_path()?;

    let mut runtime = Runtime {
        output,
        config,
        config_path,
        profile_override: cli.profile,
        api_url_override: cli.api_url,
        timeout_ms: cli.timeout,
        retries: cli.retries,
    };

    match cli.command {
        Commands::Config { command } => commands::config::handle(&mut runtime, command).await,
        Commands::Auth { command } => commands::auth::handle(&mut runtime, command).await,
        Commands::Workspaces { command } => commands::workspaces::handle(&runtime, command).await,
        Commands::Tools { command } => commands::tools::handle(&runtime, command).await,
        Commands::Whoami => commands::whoami::handle(&runtime).await,
        Commands::Chat(args) => commands::chat::handle(&runtime, args).await,
        Commands::Tui(args) => commands::tui::handle(&runtime, args).await,
        Commands::Usage(args) => commands::usage::handle(&runtime, args).await,
        Commands::Billing { command } => commands::billing::handle(&runtime, command).await,
        Commands::Health => commands::health::handle(&runtime).await,
    }
}

errors.rs

Path: starbot_tui/cli/src/errors.rs

Size: 2377 bytes

use std::fmt;

use thiserror::Error;

#[derive(Debug, Clone, Copy)]
pub enum ExitCode {
    Generic = 1,
    Auth = 2,
    Usage = 3,
    Network = 4,
    RateLimited = 5,
    Server = 6,
}

#[derive(Debug, Error)]
pub enum CliError {
    #[error("{0}")]
    Usage(String),
    #[error("{0}")]
    Auth(String),
    #[error("{0}")]
    Network(String),
    #[error("{0}")]
    RateLimited(String),
    #[error("{0}")]
    Server(String),
    #[error("{0}")]
    Generic(String),
}

impl CliError {
    pub fn exit_code(&self) -> i32 {
        match self {
            CliError::Usage(_) => ExitCode::Usage as i32,
            CliError::Auth(_) => ExitCode::Auth as i32,
            CliError::Network(_) => ExitCode::Network as i32,
            CliError::RateLimited(_) => ExitCode::RateLimited as i32,
            CliError::Server(_) => ExitCode::Server as i32,
            CliError::Generic(_) => ExitCode::Generic as i32,
        }
    }
}

impl From<std::io::Error> for CliError {
    fn from(value: std::io::Error) -> Self {
        CliError::Generic(format!("I/O error: {value}"))
    }
}

impl From<serde_json::Error> for CliError {
    fn from(value: serde_json::Error) -> Self {
        CliError::Generic(format!("JSON error: {value}"))
    }
}

impl From<url::ParseError> for CliError {
    fn from(value: url::ParseError) -> Self {
        CliError::Usage(format!("Invalid URL: {value}"))
    }
}

impl From<reqwest::Error> for CliError {
    fn from(value: reqwest::Error) -> Self {
        if value.is_timeout() {
            return CliError::Network("Request timed out.".to_string());
        }
        CliError::Network(format!("Network request failed: {value}"))
    }
}

pub fn with_debug_hint(message: &str, debug: bool) -> String {
    if debug {
        return message.to_string();
    }
    format!("{message} (try --debug for details)")
}

pub fn redact_secret(input: &str) -> String {
    if input.is_empty() {
        return String::new();
    }
    let mut out = String::new();
    let bytes = input.as_bytes();
    for (idx, b) in bytes.iter().enumerate() {
        if idx < 3 || idx + 3 >= bytes.len() {
            out.push(*b as char);
        } else {
            out.push('*');
        }
    }
    out
}

impl fmt::Display for ExitCode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", *self as i32)
    }
}

cute.rs

Path: starbot_tui/cli/src/cute.rs

Size: 2043 bytes

use std::fs;
use std::path::PathBuf;

use crate::output::OutputMode;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CuteMode {
    On,
    Minimal,
    Off,
}

impl CuteMode {
    pub fn parse(value: &str) -> Option<Self> {
        match value.trim().to_ascii_lowercase().as_str() {
            "on" => Some(Self::On),
            "minimal" => Some(Self::Minimal),
            "off" => Some(Self::Off),
            _ => None,
        }
    }
}

pub fn print_banner(mode: &OutputMode) {
    if mode.json || mode.quiet {
        return;
    }

    if load_cute_mode() != CuteMode::On {
        return;
    }

    let banner = r#"
      ★   *      .       .   *   .   *
   .     *      .    ★     .      .
        ███████╗████████╗ █████╗ ██████╗ ██████╗  ██████╗ ████████╗████████╗
        ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██╔══██╗██╔═══██╗╚══██╔══╝╚══██╔══╝
        ███████╗   ██║   ███████║██████╔╝██████╔╝██║   ██║   ██║      ██║
        ╚════██║   ██║   ██╔══██║██╔══██╗██╔══██╗██║   ██║   ██║      ██║
  ★     ███████║   ██║   ██║  ██║██║  ██║██████╔╝╚██████╔╝   ██║      ██║    ★
        ╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝╚═════╝  ╚═════╝    ╚═╝      ╚═╝
    .    *    .   *      .   *   .   *
"#;
    eprintln!("{}", banner);
}

pub fn cute_config_path() -> Option<PathBuf> {
    let home = dirs::home_dir()?;
    Some(home.join(".starbott").join("config"))
}

pub fn load_cute_mode() -> CuteMode {
    let Some(path) = cute_config_path() else {
        return CuteMode::On;
    };

    let Ok(raw) = fs::read_to_string(path) else {
        return CuteMode::On;
    };

    for line in raw.lines() {
        let l = line.trim();
        if l.is_empty() || l.starts_with('#') {
            continue;
        }

        let (key, value) = match l.split_once('=') {
            Some(kv) => kv,
            None => continue,
        };

        if key.trim().eq_ignore_ascii_case("cute") {
            if let Some(mode) = CuteMode::parse(value) {
                return mode;
            }
        }
    }

    CuteMode::On
}

mod.rs

Path: starbot_tui/cli/src/tui/mod.rs

Size: 213 bytes

// PHASE 3: TUI module - refactored from monolithic tui.rs

pub mod types;
pub mod handlers;

pub use types::*;
pub use handlers::*;

// Re-export main entry point
pub use crate::commands::tui::{TuiArgs, handle};

types.rs

Path: starbot_tui/cli/src/tui/types.rs

Size: 8574 bytes

// PHASE 3: TUI type definitions extracted from tui.rs
// Contains all structs, enums, and their implementations

use std::time::Instant;
use ratatui::widgets::ListState;
use serde_json::Value;

use crate::config::CliConfig;
use crate::cute::CuteMode;
use crate::api::ApiResponse;
use crate::errors::CliError;

// ============================================================================
// Request routing types
// ============================================================================

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lane {
    Quick,
    Standard,
    Deep,
}

impl Lane {
    pub fn from_str(value: &str) -> Option<Self> {
        match value.trim().to_ascii_lowercase().as_str() {
            "quick" => Some(Self::Quick),
            "standard" => Some(Self::Standard),
            "deep" => Some(Self::Deep),
            _ => None,
        }
    }
}

// ============================================================================
// Model and workspace picker types
// ============================================================================

#[derive(Debug, Clone)]
pub struct ModelOption {
    pub provider: String,
    pub model: Option<String>,
    pub label: String,
}

#[derive(Debug, Clone)]
pub struct WorkspaceOption {
    pub id: String,
    pub name: String,
    pub root_path: Option<String>,
    pub archived: bool,
    pub last_used_at: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ThreadOption {
    pub id: String,
    pub title: String,
    pub mode: Option<String>,
    pub last_message_at: Option<String>,
    pub is_pinned: bool,
    pub message_count: usize,
}

// ============================================================================
// Memory management types (Phase 5)
// ============================================================================

#[derive(Debug, Clone)]
pub struct MemoryItem {
    pub id: String,
    pub scope: String,       // "global" or "project"
    pub project_id: Option<String>,
    pub item_type: String,   // "fact", "preference", "decision", "constraint", "todo"
    pub content: String,
    pub tags: Vec<String>,
    pub salience: f64,       // 0.0-1.0 importance score
    pub confidence: f64,     // 0.0-1.0 confidence score
    pub source: String,      // "manual", "auto", etc.
    pub enabled: bool,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Debug, Clone)]
pub struct MemorySettings {
    pub enabled: bool,
    pub allow_auto_capture: bool,
    pub max_context_tokens: usize,
    pub max_items_injected: usize,
    pub include_global: bool,
    pub include_project: bool,
}

impl Default for MemorySettings {
    fn default() -> Self {
        Self {
            enabled: true,
            allow_auto_capture: true,
            max_context_tokens: 600,
            max_items_injected: 12,
            include_global: true,
            include_project: true,
        }
    }
}

// ============================================================================
// Choice prompt modal types (SPEC17)
// ============================================================================

#[derive(Debug, Clone)]
pub enum ChoiceAction {
    SetWorkspace { workspace_id: String },
    Tool { tool_name: String, input: Value },
    Input { prompt: String },
    SendMessage { text: String },
}

#[derive(Debug, Clone)]
pub struct ChoiceOption {
    pub id: String,
    pub label: String,
    pub description: String,
    pub action: ChoiceAction,
}

#[derive(Debug, Clone)]
pub struct ChoicePrompt {
    pub id: String,
    pub title: String,
    pub hint: String,
    pub allow_custom: bool,
    pub custom_placeholder: String,
    pub options: Vec<ChoiceOption>,
}

#[derive(Debug, Clone)]
pub struct TextPromptState {
    pub prompt: String,
    pub input: Vec<char>,
    pub cursor: usize,
}

// ============================================================================
// Chat message types
// ============================================================================

#[derive(Debug, Clone)]
pub struct ChatMsg {
    pub role: ChatRole,
    pub content: String,
    pub sendable: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatRole {
    User,
    Assistant,
    System,
}

impl ChatRole {
    pub fn as_str(self) -> &'static str {
        match self {
            ChatRole::User => "user",
            ChatRole::Assistant => "assistant",
            ChatRole::System => "system",
        }
    }
}

// ============================================================================
// UI mode enum
// ============================================================================

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Chat,
    ModelPicker,
    WorkspacePicker,
    ThreadPicker,
    MemoryPanel,
    ChoiceModal,
    TextPromptModal,
    Help,
    ToolCard,
}

// ============================================================================
// Provider health hints
// ============================================================================

#[derive(Debug, Clone, Copy)]
pub struct ProviderHints {
    pub vertex_ok: Option<bool>,
    pub azure_present: bool,
    pub cf_present: bool,
}

// ============================================================================
// Tool approval types
// ============================================================================

#[derive(Debug, Clone)]
pub struct PendingToolCard {
    pub tool_name: String,
    pub target_files: Vec<String>,
    pub preview: String,
    pub requires_confirmation: bool,
}

#[derive(Debug, Clone)]
pub struct ToolApprovalEntry {
    pub tool_name: String,
    pub approved: bool,
}

// ============================================================================
// Main App state
// ============================================================================

#[derive(Debug)]
pub struct App {
    pub mode: Mode,
    pub should_quit: bool,

    pub api_url: String,
    pub config: CliConfig,
    pub profile: String,
    pub token_present: bool,
    pub cute: CuteMode,
    pub rng: u64,
    pub last_phrase: Option<&'static str>,
    pub success_count: u32,
    pub lane: Option<Lane>,
    pub hints: ProviderHints,
    pub spinner_step: u64,
    pub spinner_last: Instant,
    pub bg_tasks: u32,

    pub messages: Vec<ChatMsg>,
    pub input: Vec<char>,
    pub cursor: usize,
    pub waiting: bool,

    pub status: String,
    pub last_request_id: Option<String>,
    pub last_elapsed_ms: Option<u128>,
    pub last_provider: Option<String>,
    pub last_model: Option<String>,
    pub last_usage: Option<String>,

    pub activity_lines: Vec<String>,
    pub current_file: Option<String>,
    pub auto_edits: bool,
    pub working_dir: String,

    pub model_options: Vec<ModelOption>,
    pub model_state: ListState,
    pub selected_provider: String,
    pub selected_model: Option<String>,

    pub workspace_options: Vec<WorkspaceOption>,
    pub workspace_state: ListState,
    pub selected_workspace_id: Option<String>,
    pub selected_workspace_name: Option<String>,
    pub pending_workspace_retry: bool,

    // Thread management state (PHASE 5)
    pub thread_options: Vec<ThreadOption>,
    pub thread_state: ListState,
    pub active_thread_id: Option<String>,
    pub active_thread_title: Option<String>,

    // Memory management state (Phase 5)
    pub memory_items: Vec<MemoryItem>,
    pub memory_state: ListState,
    pub memory_settings: MemorySettings,
    pub memory_enabled: bool,  // Quick access to enabled status

    // Choice prompt modal state (SPEC17)
    pub choice_prompt: Option<ChoicePrompt>,
    pub choice_state: ListState,
    pub text_prompt: Option<TextPromptState>,

    pub scroll_from_bottom: usize,
    pub show_debug: bool,

    // Tool card state
    pub pending_tool: Option<PendingToolCard>,
    pub tool_approval_history: Vec<ToolApprovalEntry>,
}

// ============================================================================
// Async message enum
// ============================================================================

#[derive(Debug)]
pub enum TuiMsg {
    Models(Result<ApiResponse, CliError>),
    Health(Result<ApiResponse, CliError>),
    Workspaces(Result<ApiResponse, CliError>),
    Threads(Result<ApiResponse, CliError>),
    Memory(Result<ApiResponse, CliError>),
    MemorySettings(Result<ApiResponse, CliError>),
    Chat(Result<ApiResponse, CliError>),
    Tool(String, Result<ApiResponse, CliError>),
    // Streaming events
    StreamToken(String),
    StreamDone(serde_json::Value),
    StreamError(String),
}

message.rs

Path: starbot_tui/cli/src/tui/handlers/message.rs

Size: 17749 bytes

// PHASE 3: TUI message handler extracted from tui.rs
// Contains handle_tui_msg function for processing async results

use tokio::sync::mpsc;

use crate::api::ApiClient;
use crate::tui::types::{App, TuiMsg, ChatMsg, ChatRole, Mode};
use crate::parse::response::{extract_reply, extract_provider_model, extract_usage_line};

// Import helper functions from parent tui module
// These remain in tui.rs for now (can be extracted to parse/format modules later)
use crate::commands::tui::{
    parse_model_options, find_selected_model_index,
    parse_vertex_ok, parse_workspace_options, parse_thread_options, parse_choice_prompt,
    parse_memory_items, parse_memory_settings,
    format_dir_listing_for_user, format_file_read_for_user,
    parse_lane, format_tool_propose_result,
    ready_status, format_success_status, format_error_status,
    remove_typing_placeholder,
};

use super::async_ops::spawn_workspaces_fetch;

pub fn handle_tui_msg(api: &ApiClient, tx: &mpsc::UnboundedSender<TuiMsg>, app: &mut App, msg: TuiMsg) {
    match msg {
        TuiMsg::Models(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
            Ok(resp) => {
                if let Some(options) = parse_model_options(&resp.json) {
                    if !options.is_empty() {
                        app.hints.azure_present = options.iter().any(|o| o.provider == "azure");
                        app.hints.cf_present = options.iter().any(|o| o.provider == "cloudflare");
                        app.model_options = options;
                        let idx = find_selected_model_index(
                            &app.model_options,
                            &app.selected_provider,
                            app.selected_model.as_deref(),
                        )
                        .unwrap_or(0);
                        app.model_state.select(Some(idx));
                        app.status = ready_status(app.cute, app.status.as_str()).to_string();
                    } else {
                        app.status = "No models returned by server.".to_string();
                    }
                } else {
                    app.status = "Failed parsing /v1/models response.".to_string();
                }
            }
            Err(err) => {
                app.status = format!("Failed loading models: {err}");
            }
        }
        }
        TuiMsg::Health(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
            Ok(resp) => {
                app.hints.vertex_ok = parse_vertex_ok(&resp.json);
            }
            Err(_) => {
                app.hints.vertex_ok = None;
            }
        }
        }
        TuiMsg::Workspaces(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
                Ok(resp) => {
                    if let Some(options) = parse_workspace_options(&resp.json) {
                        app.workspace_options = options;
                        if app.workspace_state.selected().is_none() && !app.workspace_options.is_empty() {
                            app.workspace_state.select(Some(0));
                        }

                        if let Some(ref sel_id) = app.selected_workspace_id {
                            if let Some(idx) = app
                                .workspace_options
                                .iter()
                                .position(|w| w.id == *sel_id)
                            {
                                app.workspace_state.select(Some(idx));
                                app.selected_workspace_name =
                                    Some(app.workspace_options[idx].name.clone());
                            } else {
                                app.selected_workspace_name = None;
                            }
                        }
                    } else {
                        app.status = "Failed parsing /v1/workspaces response.".to_string();
                    }
                }
                Err(err) => {
                    app.status = format!("Failed loading workspaces: {err}");
                }
            }
        }
        TuiMsg::Threads(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
                Ok(resp) => {
                    if let Some(options) = parse_thread_options(&resp.json) {
                        app.thread_options = options;
                        if app.thread_state.selected().is_none() && !app.thread_options.is_empty() {
                            app.thread_state.select(Some(0));
                        }

                        // Restore selection if we have an active thread
                        if let Some(ref thread_id) = app.active_thread_id {
                            if let Some(idx) = app
                                .thread_options
                                .iter()
                                .position(|t| t.id == *thread_id)
                            {
                                app.thread_state.select(Some(idx));
                                app.active_thread_title =
                                    Some(app.thread_options[idx].title.clone());
                            } else {
                                app.active_thread_title = None;
                            }
                        }
                    } else {
                        app.status = "Failed parsing /v1/threads response.".to_string();
                    }
                }
                Err(err) => {
                    app.status = format!("Failed loading threads: {err}");
                }
            }
        }
        TuiMsg::Memory(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
                Ok(resp) => {
                    if let Some(options) = parse_memory_items(&resp.json) {
                        app.memory_items = options;
                        if app.memory_state.selected().is_none() && !app.memory_items.is_empty() {
                            app.memory_state.select(Some(0));
                        }
                        app.status = format!("Loaded {} memory items", app.memory_items.len());
                    } else {
                        app.status = "Failed parsing /v1/memory response.".to_string();
                    }
                }
                Err(err) => {
                    app.status = format!("Failed loading memory: {err}");
                }
            }
        }
        TuiMsg::MemorySettings(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
                Ok(resp) => {
                    if let Some(settings) = parse_memory_settings(&resp.json) {
                        app.memory_settings = settings;
                        app.memory_enabled = app.memory_settings.enabled;
                        app.status = format!(
                            "Memory: {} (max {} items)",
                            if app.memory_enabled { "enabled" } else { "disabled" },
                            app.memory_settings.max_items_injected
                        );
                    } else {
                        app.status = "Failed parsing /v1/memory/settings response.".to_string();
                    }
                }
                Err(err) => {
                    app.status = format!("Failed loading memory settings: {err}");
                }
            }
        }
        TuiMsg::Chat(res) => {
            app.waiting = false;
            remove_typing_placeholder(app);
            match res {
                Ok(resp) => {
                    app.last_request_id = resp.request_id.clone();
                    app.last_elapsed_ms = Some(resp.elapsed_ms);

                    let need_workspace = resp
                        .json
                        .get("needWorkspace")
                        .and_then(|v| v.as_bool())
                        .unwrap_or(false);
                    let choice_prompt = parse_choice_prompt(&resp.json);

                    if need_workspace && choice_prompt.is_none() {
                        if let Some(options) = parse_workspace_options(&resp.json) {
                            app.workspace_options = options;
                            if !app.workspace_options.is_empty() {
                                app.workspace_state.select(Some(0));
                            }
                        } else if app.token_present {
                            app.bg_tasks = app.bg_tasks.saturating_add(1);
                            spawn_workspaces_fetch(api.clone(), tx.clone());
                        }

                        let msg = extract_reply(&resp.json)
                            .unwrap_or_else(|| "Pick a workspace to use, then retry your request.".to_string());
                        app.messages.push(ChatMsg {
                            role: ChatRole::System,
                            content: msg,
                            sendable: false,
                        });
                        app.pending_workspace_retry = true;
                        app.mode = Mode::WorkspacePicker;
                        app.status = "Workspace required. Select one (F3).".to_string();
                        return;
                    }

                    // Show server auto-tools deterministically in the chat (UI-only).
                    if let Some(auto_tools) = resp.json.get("autoTools").and_then(|v| v.as_array())
                    {
                        for t in auto_tools {
                            let tool_name = t
                                .get("toolName")
                                .and_then(|v| v.as_str())
                                .unwrap_or("");
                            if tool_name == "file.dir" {
                                if let Some(result) = t.get("result") {
                                    if let Some(listing) = format_dir_listing_for_user(result) {
                                        app.messages.push(ChatMsg {
                                            role: ChatRole::System,
                                            content: listing,
                                            sendable: false,
                                        });
                                    }
                                }
                            } else if tool_name == "file.read" {
                                if let Some(result) = t.get("result") {
                                    if let Some(text) = format_file_read_for_user(result) {
                                        app.messages.push(ChatMsg {
                                            role: ChatRole::System,
                                            content: text,
                                            sendable: false,
                                        });
                                    }
                                }
                            }
                        }
                    }

                    app.lane = parse_lane(&resp.json);
                    app.success_count = app.success_count.saturating_add(1);

                    let reply =
                        extract_reply(&resp.json).unwrap_or_else(|| "(No text response)".to_string());
                    app.messages.push(ChatMsg {
                        role: ChatRole::Assistant,
                        content: reply,
                        sendable: true,
                    });

                    if let Some(cp) = choice_prompt {
                        let title = cp.title.clone();
                        let hint = if cp.hint.trim().is_empty() {
                            "Tab/↑↓ select • Enter choose • Esc cancel".to_string()
                        } else {
                            cp.hint.clone()
                        };
                        app.messages.push(ChatMsg {
                            role: ChatRole::System,
                            content: format!("Choice: {title}\n{hint}"),
                            sendable: false,
                        });
                        app.choice_prompt = Some(cp);
                        app.choice_state.select(Some(0));
                        app.mode = Mode::ChoiceModal;
                        app.status = "Choice prompt".to_string();
                        if need_workspace
                            || resp
                                .json
                                .get("error")
                                .and_then(|v| v.as_str())
                                .map(|s| s == "NO_WORKSPACE_SELECTED")
                                .unwrap_or(false)
                        {
                            app.pending_workspace_retry = true;
                        }
                    }

                    let (provider, model) = extract_provider_model(&resp.json);
                    app.last_provider = provider.clone();
                    app.last_model = model.clone();
                    app.activity_lines.push(format!(
                        "Completed: {} via {}",
                        model.as_deref().unwrap_or("-"),
                        provider.as_deref().unwrap_or("-"),
                    ));
                    let usage = extract_usage_line(&resp.json);
                    app.last_usage = Some(usage);
                    app.status = format_success_status(
                        app.cute,
                        &mut app.rng,
                        &mut app.last_phrase,
                        app.success_count,
                        app.lane,
                    );
                }
                Err(err) => {
                    app.messages.push(ChatMsg {
                        role: ChatRole::System,
                        content: format!("Error: {err}"),
                        sendable: false,
                    });
                    app.status =
                        format_error_status(app.cute, &mut app.rng, &mut app.last_phrase, &err);
                }
            }
        }
        TuiMsg::Tool(tool_name, res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            app.waiting = false;
            match res {
                Ok(resp) => {
                    let rendered = format_tool_propose_result(&tool_name, &resp.json);
                    app.messages.push(ChatMsg {
                        role: ChatRole::System,
                        content: rendered,
                        sendable: true,
                    });
                    app.status = format!("Tool completed: {tool_name}");
                }
                Err(err) => {
                    app.messages.push(ChatMsg {
                        role: ChatRole::System,
                        content: format!("Tool error ({tool_name}): {err}"),
                        sendable: false,
                    });
                    app.status = format!("Tool failed: {tool_name}");
                }
            }
        }
        TuiMsg::StreamToken(token) => {
            // Append token to the last assistant message (streaming)
            if let Some(last_msg) = app.messages.last_mut() {
                if last_msg.role == ChatRole::Assistant && !last_msg.sendable {
                    // This is the streaming placeholder message
                    if last_msg.content == "…" {
                        last_msg.content = token;
                    } else {
                        last_msg.content.push_str(&token);
                    }
                }
            }
        }
        TuiMsg::StreamDone(metadata) => {
            // Streaming complete - mark message as complete and extract metadata
            app.waiting = false;

            if let Some(last_msg) = app.messages.last_mut() {
                if last_msg.role == ChatRole::Assistant && !last_msg.sendable {
                    // Mark as sendable (complete)
                    last_msg.sendable = true;

                    // If still placeholder, replace with "(No response)"
                    if last_msg.content == "…" {
                        last_msg.content = "(No text response)".to_string();
                    }
                }
            }

            // Extract metadata
            let (provider, model) = extract_provider_model(&metadata);
            app.last_provider = provider.clone();
            app.last_model = model.clone();
            app.activity_lines.push(format!(
                "Completed: {} via {}",
                model.as_deref().unwrap_or("-"),
                provider.as_deref().unwrap_or("-"),
            ));

            let usage = extract_usage_line(&metadata);
            app.last_usage = Some(usage);

            app.lane = parse_lane(&metadata);
            app.success_count = app.success_count.saturating_add(1);
            app.status = format_success_status(
                app.cute,
                &mut app.rng,
                &mut app.last_phrase,
                app.success_count,
                app.lane,
            );
        }
        TuiMsg::StreamError(error) => {
            app.waiting = false;
            remove_typing_placeholder(app);

            app.messages.push(ChatMsg {
                role: ChatRole::System,
                content: format!("Streaming error: {error}"),
                sendable: false,
            });
            app.status = format_error_status(
                app.cute,
                &mut app.rng,
                &mut app.last_phrase,
                &crate::errors::CliError::Generic(error),
            );
        }
    }
}

mod.rs

Path: starbot_tui/cli/src/tui/handlers/mod.rs

Size: 163 bytes

// PHASE 3: TUI handler modules extracted from tui.rs

pub mod async_ops;
pub mod key;
pub mod message;

pub use async_ops::*;
pub use key::*;
pub use message::*;

async_ops.rs

Path: starbot_tui/cli/src/tui/handlers/async_ops.rs

Size: 5762 bytes

// PHASE 3: Async operation handlers extracted from tui.rs
// Contains spawn_* functions for background API requests

use serde_json::{Value, json};
use tokio::sync::mpsc;

use crate::api::ApiClient;
use crate::tui::types::{TuiMsg, ChatMsg};

pub fn spawn_models_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        let res = api.get_json("/v1/models", None, false).await;
        let _ = tx.send(TuiMsg::Models(res));
    });
}

pub fn spawn_health_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        let res = api.get_json("/health", None, false).await;
        let _ = tx.send(TuiMsg::Health(res));
    });
}

pub fn spawn_workspaces_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        let res = api.get_json("/v1/workspaces", None, true).await;
        let _ = tx.send(TuiMsg::Workspaces(res));
    });
}

pub fn spawn_threads_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        let res = api.get_json("/v1/threads", None, true).await;
        let _ = tx.send(TuiMsg::Threads(res));
    });
}

pub fn spawn_memory_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        let res = api.get_json("/v1/memory?limit=50", None, true).await;
        let _ = tx.send(TuiMsg::Memory(res));
    });
}

pub fn spawn_memory_settings_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        let res = api.get_json("/v1/memory/settings", None, true).await;
        let _ = tx.send(TuiMsg::MemorySettings(res));
    });
}

pub fn spawn_memory_toggle(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>, enabled: bool) {
    tokio::spawn(async move {
        let body = json!({ "enabled": enabled });
        let res = api.post_json("/v1/memory/settings", Some(body), true).await;
        let _ = tx.send(TuiMsg::MemorySettings(res));
    });
}

pub fn spawn_tool_propose(
    api: ApiClient,
    tx: mpsc::UnboundedSender<TuiMsg>,
    tool_name: String,
    workspace_id: String,
    input: Value,
) {
    tokio::spawn(async move {
        let body = json!({
            "workspaceId": workspace_id,
            "toolName": tool_name,
            "input": input,
        });
        let tool = body
            .get("toolName")
            .and_then(|v| v.as_str())
            .unwrap_or("tool")
            .to_string();
        let res = api.post_json("/v1/tools/propose", Some(body), true).await;
        let _ = tx.send(TuiMsg::Tool(tool, res));
    });
}

pub fn spawn_chat_request(
    api: ApiClient,
    tx: mpsc::UnboundedSender<TuiMsg>,
    provider: String,
    model: Option<String>,
    messages: Vec<ChatMsg>,
    workspace_id: Option<String>,
) {
    tokio::spawn(async move {
        let body = build_chat_body(&provider, model.as_deref(), &messages, workspace_id.as_deref());
        let res = api.post_json("/v1/inference/chat", Some(body), true).await;
        let _ = tx.send(TuiMsg::Chat(res));
    });
}

/// Streaming version of chat request (SSE)
pub fn spawn_chat_request_stream(
    api: ApiClient,
    tx: mpsc::UnboundedSender<TuiMsg>,
    provider: String,
    model: Option<String>,
    messages: Vec<ChatMsg>,
    workspace_id: Option<String>,
) {
    tokio::spawn(async move {
        let body = build_chat_body(&provider, model.as_deref(), &messages, workspace_id.as_deref());

        match api.post_stream("/v1/inference/chat/stream", Some(body), true).await {
            Ok(mut rx) => {
                let mut final_metadata = json!({});

                while let Some(event) = rx.recv().await {
                    match event.event_type.as_str() {
                        "token" => {
                            // Parse token data
                            if let Ok(data) = serde_json::from_str::<Value>(&event.data) {
                                if let Some(text) = data.get("text").and_then(|v| v.as_str()) {
                                    let _ = tx.send(TuiMsg::StreamToken(text.to_string()));
                                }
                            }
                        }
                        "done" => {
                            // Parse final metadata
                            if let Ok(data) = serde_json::from_str::<Value>(&event.data) {
                                final_metadata = data;
                            }
                        }
                        _ => {
                            // Ignore other events for now (job_state, tool_proposal, etc.)
                        }
                    }
                }

                // Send final done message
                let _ = tx.send(TuiMsg::StreamDone(final_metadata));
            }
            Err(e) => {
                let _ = tx.send(TuiMsg::StreamError(e.to_string()));
            }
        }
    });
}

fn build_chat_body(
    provider: &str,
    model: Option<&str>,
    messages: &[ChatMsg],
    workspace_id: Option<&str>,
) -> Value {
    let mut body = json!({
        // Only send actual conversation turns. Local UI/system messages (errors, hints) should not
        // be fed back into the model.
        "messages": messages
            .iter()
            .filter(|m| m.sendable)
            .map(|m| json!({"role": m.role.as_str(), "content": m.content}))
            .collect::<Vec<_>>(),
        "client": "cli",
        "provider": provider,
        "toolsEnabled": true,
    });
    if let Some(m) = model.map(str::trim).filter(|s| !s.is_empty()) {
        body["model"] = json!(m);
    }
    if let Some(ws) = workspace_id.map(str::trim).filter(|s| !s.is_empty()) {
        body["workspaceId"] = json!(ws);
    }
    body
}

key.rs

Path: starbot_tui/cli/src/tui/handlers/key.rs

Size: 27827 bytes

// PHASE 3: TUI key handler extracted from tui.rs
// Contains handle_event and handle_key functions for keyboard input

use std::time::Instant;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::widgets::ListState;
use tokio::sync::mpsc;

use crate::api::ApiClient;
use crate::config::{profile_mut, save_config};
use crate::cute::CuteMode;
use crate::errors::CliError;
use crate::tui::types::{
    App, TuiMsg, ChatMsg, ChatRole, Mode, ChoiceAction, TextPromptState, ToolApprovalEntry,
};

use super::async_ops::{
    spawn_models_fetch, spawn_health_fetch, spawn_workspaces_fetch, spawn_threads_fetch,
    spawn_memory_fetch, spawn_memory_settings_fetch, spawn_memory_toggle,
    spawn_chat_request_stream, spawn_tool_propose,
};

// Import helper functions from parent tui module
use crate::commands::tui::thinking_status;

pub fn handle_event(
    api: &ApiClient,
    tx: &mpsc::UnboundedSender<TuiMsg>,
    app: &mut App,
    event: Event,
) -> Result<(), CliError> {
    match event {
        Event::Key(key) if key.kind == KeyEventKind::Press => {
            handle_key(api, tx, app, key)?;
        }
        _ => {}
    }
    Ok(())
}

pub fn handle_key(
    api: &ApiClient,
    tx: &mpsc::UnboundedSender<TuiMsg>,
    app: &mut App,
    key: crossterm::event::KeyEvent,
) -> Result<(), CliError> {
    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);

    match app.mode {
        Mode::Help => {
            if key.code == KeyCode::Esc
                || key.code == KeyCode::Enter
                || key.code == KeyCode::Char('q')
            {
                app.mode = Mode::Chat;
            }
            return Ok(());
        }
        Mode::ToolCard => {
            match key.code {
                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
                    if let Some(tool) = app.pending_tool.take() {
                        app.tool_approval_history.push(ToolApprovalEntry {
                            tool_name: tool.tool_name.clone(),
                            approved: true,
                        });
                        app.messages.push(ChatMsg {
                            role: ChatRole::System,
                            content: format!("Approved tool: {}", tool.tool_name),
                            sendable: false,
                        });
                        app.status = format!("Tool approved: {}", tool.tool_name);
                    }
                    app.mode = Mode::Chat;
                }
                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
                    if let Some(tool) = app.pending_tool.take() {
                        app.tool_approval_history.push(ToolApprovalEntry {
                            tool_name: tool.tool_name.clone(),
                            approved: false,
                        });
                        app.messages.push(ChatMsg {
                            role: ChatRole::System,
                            content: format!("Denied tool: {}", tool.tool_name),
                            sendable: false,
                        });
                        app.status = format!("Tool denied: {}", tool.tool_name);
                    }
                    app.mode = Mode::Chat;
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::ModelPicker => {
            match key.code {
                KeyCode::Esc => app.mode = Mode::Chat,
                KeyCode::Up => move_selection(&mut app.model_state, -1, app.model_options.len()),
                KeyCode::Down => move_selection(&mut app.model_state, 1, app.model_options.len()),
                KeyCode::PageUp => {
                    move_selection(&mut app.model_state, -5, app.model_options.len())
                }
                KeyCode::PageDown => {
                    move_selection(&mut app.model_state, 5, app.model_options.len())
                }
                KeyCode::Enter => {
                    if let Some(idx) = app.model_state.selected() {
                        if let Some(opt) = app.model_options.get(idx) {
                            app.selected_provider = opt.provider.clone();
                            app.selected_model = opt.model.clone();
                            // Avoid putting the full model id in the status line; the bottom panel
                            // already shows the selected model separately.
                            app.status = if app.selected_provider == "auto" {
                                "Selected auto".to_string()
                            } else {
                                format!("Selected {}", app.selected_provider)
                            };
                        }
                    }
                    app.mode = Mode::Chat;
                }
                KeyCode::Char('r') if ctrl => {
                    app.status = "Reloading models...".to_string();
                    app.bg_tasks = app.bg_tasks.saturating_add(1);
                    spawn_models_fetch(api.clone(), tx.clone());
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::WorkspacePicker => {
            match key.code {
                KeyCode::Esc => {
                    app.pending_workspace_retry = false;
                    app.mode = Mode::Chat;
                }
                KeyCode::Up => {
                    move_selection(&mut app.workspace_state, -1, app.workspace_options.len())
                }
                KeyCode::Down => {
                    move_selection(&mut app.workspace_state, 1, app.workspace_options.len())
                }
                KeyCode::PageUp => {
                    move_selection(&mut app.workspace_state, -5, app.workspace_options.len())
                }
                KeyCode::PageDown => {
                    move_selection(&mut app.workspace_state, 5, app.workspace_options.len())
                }
                KeyCode::Enter => {
                    if let Some(idx) = app.workspace_state.selected() {
                        let (id, name, archived) = match app.workspace_options.get(idx) {
                            Some(opt) => (opt.id.clone(), opt.name.clone(), opt.archived),
                            None => ("".to_string(), "".to_string(), false),
                        };
                        if id.trim().is_empty() {
                            app.status = "No workspace selected.".to_string();
                            return Ok(());
                        }
                        if archived {
                            app.status = format!("Workspace is archived: {name}");
                            return Ok(());
                        }
                        apply_workspace_selection(app, &id, Some(&name));
                        app.status = format!("Workspace: {name}");
                    }
                    app.mode = Mode::Chat;

                    if app.pending_workspace_retry {
                        app.pending_workspace_retry = false;
                        retry_last_chat(api, tx, app);
                    }
                }
                KeyCode::Char('r') if ctrl => {
                    if !app.token_present {
                        app.status = "Missing token. Run `starbott auth login` first.".to_string();
                        return Ok(());
                    }
                    app.status = "Reloading workspaces...".to_string();
                    app.bg_tasks = app.bg_tasks.saturating_add(1);
                    spawn_workspaces_fetch(api.clone(), tx.clone());
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::ThreadPicker => {
            match key.code {
                KeyCode::Esc => app.mode = Mode::Chat,
                KeyCode::Up => {
                    move_selection(&mut app.thread_state, -1, app.thread_options.len())
                }
                KeyCode::Down => {
                    move_selection(&mut app.thread_state, 1, app.thread_options.len())
                }
                KeyCode::PageUp => {
                    move_selection(&mut app.thread_state, -5, app.thread_options.len())
                }
                KeyCode::PageDown => {
                    move_selection(&mut app.thread_state, 5, app.thread_options.len())
                }
                KeyCode::Enter => {
                    if let Some(idx) = app.thread_state.selected() {
                        if let Some(thread) = app.thread_options.get(idx) {
                            app.active_thread_id = Some(thread.id.clone());
                            app.active_thread_title = Some(thread.title.clone());
                            app.status = format!("Switched to thread: {}", thread.title);
                            // Clear current messages when switching threads
                            app.messages.clear();
                        }
                    }
                    app.mode = Mode::Chat;
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::MemoryPanel => {
            match key.code {
                KeyCode::Esc => app.mode = Mode::Chat,
                KeyCode::Up => {
                    move_selection(&mut app.memory_state, -1, app.memory_items.len())
                }
                KeyCode::Down => {
                    move_selection(&mut app.memory_state, 1, app.memory_items.len())
                }
                KeyCode::PageUp => {
                    move_selection(&mut app.memory_state, -5, app.memory_items.len())
                }
                KeyCode::PageDown => {
                    move_selection(&mut app.memory_state, 5, app.memory_items.len())
                }
                KeyCode::Char('m') if ctrl => {
                    // Toggle memory
                    app.memory_enabled = !app.memory_enabled;
                    app.status = format!("Toggling memory {}...", if app.memory_enabled { "ON" } else { "OFF" });
                    app.bg_tasks = app.bg_tasks.saturating_add(1);
                    spawn_memory_toggle(api.clone(), tx.clone(), app.memory_enabled);
                }
                KeyCode::Char('r') if ctrl => {
                    // Refresh memory list
                    app.status = "Refreshing memory...".to_string();
                    app.bg_tasks = app.bg_tasks.saturating_add(2);
                    spawn_memory_fetch(api.clone(), tx.clone());
                    spawn_memory_settings_fetch(api.clone(), tx.clone());
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::ChoiceModal => {
            match key.code {
                KeyCode::Esc => {
                    app.choice_prompt = None;
                    app.mode = Mode::Chat;
                }
                KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
                    let len = app
                        .choice_prompt
                        .as_ref()
                        .map(|p| p.options.len())
                        .unwrap_or(0);
                    move_selection_wrap(&mut app.choice_state, 1, len);
                }
                KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') => {
                    let len = app
                        .choice_prompt
                        .as_ref()
                        .map(|p| p.options.len())
                        .unwrap_or(0);
                    move_selection_wrap(&mut app.choice_state, -1, len);
                }
                KeyCode::PageUp => {
                    let len = app
                        .choice_prompt
                        .as_ref()
                        .map(|p| p.options.len())
                        .unwrap_or(0);
                    move_selection(&mut app.choice_state, -5, len);
                }
                KeyCode::PageDown => {
                    let len = app
                        .choice_prompt
                        .as_ref()
                        .map(|p| p.options.len())
                        .unwrap_or(0);
                    move_selection(&mut app.choice_state, 5, len);
                }
                KeyCode::Enter => {
                    let Some(prompt) = app.choice_prompt.clone() else {
                        app.mode = Mode::Chat;
                        return Ok(());
                    };
                    let Some(idx) = app.choice_state.selected() else {
                        return Ok(());
                    };
                    let Some(opt) = prompt.options.get(idx).cloned() else {
                        return Ok(());
                    };

                    // Close modal before executing.
                    app.choice_prompt = None;
                    app.mode = Mode::Chat;

                    match opt.action {
                        ChoiceAction::SetWorkspace { workspace_id } => {
                            apply_workspace_selection(app, &workspace_id, Some(&opt.label));
                            app.status = format!("Workspace: {}", opt.label);

                            if app.pending_workspace_retry {
                                app.pending_workspace_retry = false;
                                retry_last_chat(api, tx, app);
                            }
                        }
                        ChoiceAction::Tool { tool_name, input } => {
                            let ws = match app.selected_workspace_id.clone() {
                                Some(v) if !v.trim().is_empty() => v,
                                _ => {
                                    app.messages.push(ChatMsg {
                                        role: ChatRole::System,
                                        content: "No workspace selected. Press F3 to pick one."
                                            .to_string(),
                                        sendable: false,
                                    });
                                    app.status = "Workspace required.".to_string();
                                    return Ok(());
                                }
                            };

                            app.messages.push(ChatMsg {
                                role: ChatRole::System,
                                content: format!("Running tool: {tool_name}"),
                                sendable: false,
                            });
                            app.waiting = true;
                            app.status = format!("Running tool: {tool_name}...");
                            app.bg_tasks = app.bg_tasks.saturating_add(1);
                            spawn_tool_propose(
                                api.clone(),
                                tx.clone(),
                                tool_name.clone(),
                                ws,
                                input,
                            );
                        }
                        ChoiceAction::Input { prompt } => {
                            app.text_prompt = Some(TextPromptState {
                                prompt,
                                input: Vec::new(),
                                cursor: 0,
                            });
                            app.mode = Mode::TextPromptModal;
                        }
                        ChoiceAction::SendMessage { text } => {
                            send_chat_text(api, tx, app, text);
                        }
                    }
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::TextPromptModal => {
            let Some(mut st) = app.text_prompt.clone() else {
                app.mode = Mode::Chat;
                return Ok(());
            };

            match key.code {
                KeyCode::Esc => {
                    app.text_prompt = None;
                    app.mode = Mode::Chat;
                }
                KeyCode::Enter => {
                    let value = st.input.iter().collect::<String>();
                    let trimmed = value.trim().to_string();
                    if !trimmed.is_empty() {
                        let msg = format!("{}\n{}", st.prompt.trim(), trimmed);
                        app.text_prompt = None;
                        app.mode = Mode::Chat;
                        send_chat_text(api, tx, app, msg);
                    }
                }
                KeyCode::Backspace => {
                    if st.cursor > 0 && st.cursor <= st.input.len() {
                        st.cursor -= 1;
                        st.input.remove(st.cursor);
                    }
                    app.text_prompt = Some(st);
                }
                KeyCode::Left => {
                    st.cursor = st.cursor.saturating_sub(1);
                    app.text_prompt = Some(st);
                }
                KeyCode::Right => {
                    st.cursor = (st.cursor + 1).min(st.input.len());
                    app.text_prompt = Some(st);
                }
                KeyCode::Home => {
                    st.cursor = 0;
                    app.text_prompt = Some(st);
                }
                KeyCode::End => {
                    st.cursor = st.input.len();
                    app.text_prompt = Some(st);
                }
                KeyCode::Char(ch) => {
                    if ctrl {
                        return Ok(());
                    }
                    if st.cursor > st.input.len() {
                        st.cursor = st.input.len();
                    }
                    st.input.insert(st.cursor, ch);
                    st.cursor += 1;
                    app.text_prompt = Some(st);
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::Chat => {}
    }

    // Mode::Chat key handling
    match key.code {
        KeyCode::Esc => app.should_quit = true,
        KeyCode::Char('c') if ctrl => app.should_quit = true,
        KeyCode::F(1) => app.mode = Mode::Help,
        KeyCode::F(2) => app.mode = Mode::ModelPicker,
        KeyCode::F(3) => {
            if !app.token_present {
                app.messages.push(ChatMsg {
                    role: ChatRole::System,
                    content: "Missing token. Run `starbott auth login` to select a workspace."
                        .to_string(),
                    sendable: false,
                });
                app.status = "Missing token.".to_string();
                return Ok(());
            }
            if app.workspace_options.is_empty() {
                app.status = "Loading workspaces...".to_string();
                app.bg_tasks = app.bg_tasks.saturating_add(1);
                spawn_workspaces_fetch(api.clone(), tx.clone());
            }
            app.mode = Mode::WorkspacePicker;
        }
        KeyCode::F(4) => {
            if !app.token_present {
                app.messages.push(ChatMsg {
                    role: ChatRole::System,
                    content: "Missing token. Run `starbott auth login` to access threads.".to_string(),
                    sendable: false,
                });
                app.status = "Missing token.".to_string();
                return Ok(());
            }
            if app.thread_options.is_empty() {
                app.status = "Loading threads...".to_string();
                app.bg_tasks = app.bg_tasks.saturating_add(1);
                spawn_threads_fetch(api.clone(), tx.clone());
            }
            app.mode = Mode::ThreadPicker;
        }
        KeyCode::F(5) => {
            if !app.token_present {
                app.messages.push(ChatMsg {
                    role: ChatRole::System,
                    content: "Missing token. Run `starbott auth login` to access memory.".to_string(),
                    sendable: false,
                });
                app.status = "Missing token.".to_string();
                return Ok(());
            }
            // Load memory items and settings on first open
            if app.memory_items.is_empty() {
                app.status = "Loading memory...".to_string();
                app.bg_tasks = app.bg_tasks.saturating_add(2);
                spawn_memory_fetch(api.clone(), tx.clone());
                spawn_memory_settings_fetch(api.clone(), tx.clone());
            }
            app.mode = Mode::MemoryPanel;
        }
        KeyCode::Char('m') if ctrl => app.mode = Mode::ModelPicker,
        KeyCode::Char('d') if ctrl => app.show_debug = !app.show_debug,
        KeyCode::Char('r') if ctrl => {
            app.status = "Reloading...".to_string();
            app.bg_tasks = app.bg_tasks.saturating_add(2);
            spawn_models_fetch(api.clone(), tx.clone());
            spawn_health_fetch(api.clone(), tx.clone());
            if app.token_present {
                app.bg_tasks = app.bg_tasks.saturating_add(1);
                spawn_workspaces_fetch(api.clone(), tx.clone());
            }
        }
        KeyCode::PageUp => app.scroll_from_bottom = app.scroll_from_bottom.saturating_add(5),
        KeyCode::PageDown => app.scroll_from_bottom = app.scroll_from_bottom.saturating_sub(5),
        KeyCode::Enter => {
            if app.waiting {
                return Ok(());
            }
            let prompt = app.input.iter().collect::<String>();
            let trimmed = prompt.trim().to_string();
            if trimmed.is_empty() {
                return Ok(());
            }

            app.messages.push(ChatMsg {
                role: ChatRole::User,
                content: trimmed,
                sendable: true,
            });
            if app.cute != CuteMode::Off {
                // UI-only typing bubble: animated via spinner_step and not sent to the API.
                app.messages.push(ChatMsg {
                    role: ChatRole::Assistant,
                    content: "…".to_string(),
                    sendable: false,
                });
            }
            app.input.clear();
            app.cursor = 0;
            app.waiting = true;
            app.spinner_step = 0;
            app.spinner_last = Instant::now();
            app.status = thinking_status(app.cute, &mut app.rng, &mut app.last_phrase).to_string();

            let provider = app.selected_provider.clone();
            let model = app.selected_model.clone();
            let messages = app.messages.clone();
            let workspace_id = app.selected_workspace_id.clone();
            spawn_chat_request_stream(api.clone(), tx.clone(), provider, model, messages, workspace_id);
        }
        KeyCode::Backspace => {
            if app.cursor > 0 && app.cursor <= app.input.len() {
                app.cursor -= 1;
                app.input.remove(app.cursor);
            }
        }
        KeyCode::Left => {
            app.cursor = app.cursor.saturating_sub(1);
        }
        KeyCode::Right => {
            app.cursor = (app.cursor + 1).min(app.input.len());
        }
        KeyCode::Home => app.cursor = 0,
        KeyCode::End => app.cursor = app.input.len(),
        KeyCode::Char(ch) => {
            if ctrl {
                return Ok(());
            }
            if app.cursor > app.input.len() {
                app.cursor = app.input.len();
            }
            app.input.insert(app.cursor, ch);
            app.cursor += 1;
        }
        _ => {}
    }

    Ok(())
}

// ============================================================================
// Helper functions for key handling
// ============================================================================

fn move_selection(state: &mut ListState, delta: isize, len: usize) {
    if len == 0 {
        state.select(None);
        return;
    }
    let cur = state.selected().unwrap_or(0) as isize;
    let mut next = cur + delta;
    if next < 0 {
        next = 0;
    }
    if next as usize >= len {
        next = (len - 1) as isize;
    }
    state.select(Some(next as usize));
}

fn move_selection_wrap(state: &mut ListState, delta: isize, len: usize) {
    if len == 0 {
        state.select(None);
        return;
    }
    let cur = state.selected().unwrap_or(0) as isize;
    let mut next = cur + delta;
    if next < 0 {
        next = (len - 1) as isize;
    }
    if next as usize >= len {
        next = 0;
    }
    state.select(Some(next as usize));
}

fn apply_workspace_selection(app: &mut App, workspace_id: &str, name_hint: Option<&str>) {
    let ws = workspace_id.trim();
    if ws.is_empty() {
        return;
    }

    app.selected_workspace_id = Some(ws.to_string());
    app.selected_workspace_name = name_hint
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string());

    if let Some(idx) = app.workspace_options.iter().position(|w| w.id == ws) {
        app.workspace_state.select(Some(idx));
        if app.selected_workspace_name.is_none() {
            app.selected_workspace_name = Some(app.workspace_options[idx].name.clone());
        }
    }

    if let Some(p) = profile_mut(&mut app.config, &app.profile) {
        p.workspace_id = Some(ws.to_string());
        if let Err(e) = save_config(&app.config) {
            app.messages.push(ChatMsg {
                role: ChatRole::System,
                content: format!("Failed saving workspace selection: {e}"),
                sendable: false,
            });
        }
    } else {
        app.messages.push(ChatMsg {
            role: ChatRole::System,
            content: "Failed saving workspace selection: profile not found.".to_string(),
            sendable: false,
        });
    }
}

fn retry_last_chat(api: &ApiClient, tx: &mpsc::UnboundedSender<TuiMsg>, app: &mut App) {
    if app.waiting {
        return;
    }
    if !app
        .messages
        .iter()
        .any(|m| m.sendable && matches!(m.role, ChatRole::User))
    {
        return;
    }

    if app.cute != CuteMode::Off {
        app.messages.push(ChatMsg {
            role: ChatRole::Assistant,
            content: "…".to_string(),
            sendable: false,
        });
    }
    app.waiting = true;
    app.spinner_step = 0;
    app.spinner_last = Instant::now();
    app.status = thinking_status(app.cute, &mut app.rng, &mut app.last_phrase).to_string();

    let provider = app.selected_provider.clone();
    let model = app.selected_model.clone();
    let messages = app.messages.clone();
    let workspace_id = app.selected_workspace_id.clone();
    spawn_chat_request_stream(api.clone(), tx.clone(), provider, model, messages, workspace_id);
}

fn send_chat_text(api: &ApiClient, tx: &mpsc::UnboundedSender<TuiMsg>, app: &mut App, text: String) {
    if app.waiting {
        return;
    }
    let trimmed = text.trim().to_string();
    if trimmed.is_empty() {
        return;
    }

    app.messages.push(ChatMsg {
        role: ChatRole::User,
        content: trimmed,
        sendable: true,
    });
    if app.cute != CuteMode::Off {
        app.messages.push(ChatMsg {
            role: ChatRole::Assistant,
            content: "…".to_string(),
            sendable: false,
        });
    }
    app.input.clear();
    app.cursor = 0;
    app.waiting = true;
    app.spinner_step = 0;
    app.spinner_last = Instant::now();
    app.status = thinking_status(app.cute, &mut app.rng, &mut app.last_phrase).to_string();

    let provider = app.selected_provider.clone();
    let model = app.selected_model.clone();
    let messages = app.messages.clone();
    let workspace_id = app.selected_workspace_id.clone();
    spawn_chat_request_stream(api.clone(), tx.clone(), provider, model, messages, workspace_id);
}

chat.rs

Path: starbot_tui/cli/src/commands/chat.rs

Size: 4200 bytes

use std::io::{self, Read};

use clap::Args;
use serde_json::{Value, json};

use crate::app::Runtime;
use crate::errors::CliError;
use crate::parse::response::{extract_reply, extract_provider_model, extract_usage_line};

#[derive(Debug, Args)]
pub struct ChatArgs {
    /// Prompt text
    pub prompt: Option<String>,
    /// Model or provider selector. Examples: "azure:gpt-5.2-chat" or "auto"
    #[arg(short = 'm', long = "model")]
    pub model: Option<String>,
    /// Optional conversation id
    #[arg(short = 'c', long = "conversation")]
    pub conversation: Option<String>,
    /// Read prompt from stdin
    #[arg(long)]
    pub stdin: bool,
    /// Request streaming response (falls back to non-streaming if unsupported)
    #[arg(long)]
    pub stream: bool,
    /// Optional max output tokens passthrough
    #[arg(long = "max-tokens")]
    pub max_tokens: Option<u32>,
}

pub async fn handle(runtime: &Runtime, args: ChatArgs) -> Result<(), CliError> {
    let prompt = resolve_prompt(&args)?;
    let api = runtime.api_client()?;

    if args.stream {
        runtime.output.print_verbose(
            "Requested --stream. Using standard response (CLI streaming output is not implemented yet).",
        );
    }

    let mut body = json!({
        "messages": [
            { "role": "user", "content": prompt }
        ],
        "client": "cli",
        "provider": "auto"
    });

    if let Some(conversation_id) = &args.conversation {
        body["conversationId"] = json!(conversation_id);
    }
    if let Some(max_tokens) = args.max_tokens {
        body["max_tokens"] = json!(max_tokens);
    }

    if let Some(model_or_provider) = &args.model {
        apply_model_selector(&mut body, model_or_provider);
    }

    let res = api.post_json("/v1/inference/chat", Some(body), true).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let reply = extract_reply(&res.json).unwrap_or_else(|| "(No text response)".to_string());
    runtime.output.print_human(&reply);

    if runtime.output.verbose {
        let (provider, model) = extract_provider_model(&res.json);
        let usage = extract_usage_line(&res.json);
        runtime.output.print_stderr(&format!(
            "provider={} model={} {}",
            provider.unwrap_or_else(|| "-".to_string()),
            model.unwrap_or_else(|| "-".to_string()),
            usage
        ));
    }

    Ok(())
}

fn resolve_prompt(args: &ChatArgs) -> Result<String, CliError> {
    if args.stdin {
        let mut input = String::new();
        io::stdin()
            .read_to_string(&mut input)
            .map_err(|e| CliError::Generic(format!("Failed reading stdin: {e}")))?;
        let trimmed = input.trim().to_string();
        if trimmed.is_empty() {
            return Err(CliError::Usage(
                "No prompt provided via stdin. Pipe text or pass a prompt argument.".to_string(),
            ));
        }
        return Ok(trimmed);
    }

    match &args.prompt {
        Some(value) if !value.trim().is_empty() => Ok(value.trim().to_string()),
        _ => Err(CliError::Usage(
            "Missing prompt. Use `starbott chat \"...\"` or pass `--stdin`.".to_string(),
        )),
    }
}

fn apply_model_selector(body: &mut Value, selector: &str) {
    let trimmed = selector.trim();
    if trimmed.is_empty() {
        return;
    }

    if let Some((provider, model)) = trimmed.split_once(':') {
        if !provider.trim().is_empty() {
            body["provider"] = json!(provider.trim());
        }
        if !model.trim().is_empty() {
            body["model"] = json!(model.trim());
        }
        return;
    }

    let lower = trimmed.to_ascii_lowercase();
    if matches!(
        lower.as_str(),
        "auto" | "kimi" | "gemini" | "vertex" | "cloudflare" | "azure" | "openai"
    ) {
        body["provider"] = json!(lower);
    } else {
        body["model"] = json!(trimmed);
    }
}

// PHASE 3: Functions moved to crate::parse::response module
// (Removed 60 lines of duplicate code)

tools.rs

Path: starbot_tui/cli/src/commands/tools.rs

Size: 9675 bytes

use std::io::{self, Read};
use std::path::PathBuf;

use clap::Subcommand;
use serde_json::{Value, json};

use crate::app::Runtime;
use crate::errors::CliError;

#[derive(Debug, Subcommand)]
pub enum ToolsCommand {
    /// Propose a tool run (safe tools execute immediately; confirm tools require approval + commit).
    Propose {
        /// Workspace id
        #[arg(long)]
        workspace_id: String,
        /// Tool name (example: file.search, file.read, file.patch, web.search)
        #[arg(long)]
        tool_name: String,
        /// Tool input as JSON string
        #[arg(long)]
        input: Option<String>,
        /// Read tool input JSON from a file
        #[arg(long)]
        input_file: Option<PathBuf>,
        /// Read tool input JSON from stdin
        #[arg(long)]
        stdin: bool,
        /// Auto-approve confirm tools (skips prompt)
        #[arg(long)]
        yes: bool,
        /// Reason to include when denying a proposal
        #[arg(long)]
        deny_reason: Option<String>,
    },
    /// Commit an approved proposal.
    Commit {
        #[arg(long)]
        proposal_id: String,
    },
    /// Deny a pending proposal.
    Deny {
        #[arg(long)]
        proposal_id: String,
        #[arg(long)]
        reason: Option<String>,
    },
    /// List your tool runs.
    Runs {
        #[arg(long)]
        workspace_id: Option<String>,
        #[arg(long)]
        tool_name: Option<String>,
        #[arg(long)]
        limit: Option<u32>,
    },
}

pub async fn handle(runtime: &Runtime, command: ToolsCommand) -> Result<(), CliError> {
    match command {
        ToolsCommand::Propose {
            workspace_id,
            tool_name,
            input,
            input_file,
            stdin,
            yes,
            deny_reason,
        } => propose(runtime, workspace_id, tool_name, input, input_file, stdin, yes, deny_reason).await,
        ToolsCommand::Commit { proposal_id } => commit(runtime, proposal_id).await,
        ToolsCommand::Deny { proposal_id, reason } => deny(runtime, proposal_id, reason).await,
        ToolsCommand::Runs {
            workspace_id,
            tool_name,
            limit,
        } => list_runs(runtime, workspace_id, tool_name, limit).await,
    }
}

async fn propose(
    runtime: &Runtime,
    workspace_id: String,
    tool_name: String,
    input: Option<String>,
    input_file: Option<PathBuf>,
    stdin: bool,
    yes: bool,
    deny_reason: Option<String>,
) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    let tool_name = tool_name.trim().to_string();
    if tool_name.is_empty() {
        return Err(CliError::Usage("--tool-name must be non-empty.".to_string()));
    }

    let input_json = read_input_json(input, input_file, stdin)?;

    let body = json!({
        "workspaceId": workspace_id.trim(),
        "toolName": tool_name,
        "input": input_json,
    });

    let res = api.post_json("/v1/tools/propose", Some(body), true).await?;
    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let requires = res
        .json
        .get("requiresConfirmation")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    if !requires {
        let run_id = res.json.get("runId").and_then(|v| v.as_str()).unwrap_or("-");
        runtime
            .output
            .print_human(&format!("tool completed: runId={run_id}"));

        if let Some(result) = res.json.get("result") {
            runtime.output.print_human(&format_json(result)?);
        }
        return Ok(());
    }

    let proposal_id = res
        .json
        .get("proposalId")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    if proposal_id.is_empty() {
        return Err(CliError::Server("Missing proposalId in response".to_string()));
    }

    let expires_at = res.json.get("expiresAt").and_then(|v| v.as_str()).unwrap_or("-");
    runtime
        .output
        .print_human(&format!("tool requires approval: proposalId={proposal_id} expiresAt={expires_at}"));

    if let Some(preview) = res.json.get("preview") {
        runtime.output.print_human(&format_json(preview)?);
    }

    let approve = if yes {
        true
    } else {
        prompt_approve()?
    };

    if approve {
        let commit_res = api
            .post_json("/v1/tools/commit", Some(json!({ "proposalId": proposal_id })), true)
            .await?;
        runtime.output.print_human("committed.");
        runtime.output.print_human(&format_json(&commit_res.json)?);
        return Ok(());
    }

    let deny_body = if let Some(reason) = deny_reason {
        json!({ "proposalId": proposal_id, "reason": reason })
    } else {
        json!({ "proposalId": proposal_id })
    };
    let deny_res = api.post_json("/v1/tools/deny", Some(deny_body), true).await?;
    runtime.output.print_human("denied.");
    runtime.output.print_human(&format_json(&deny_res.json)?);
    Ok(())
}

async fn commit(runtime: &Runtime, proposal_id: String) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let pid = proposal_id.trim();
    if pid.is_empty() {
        return Err(CliError::Usage("--proposal-id is required.".to_string()));
    }

    let res = api
        .post_json("/v1/tools/commit", Some(json!({ "proposalId": pid })), true)
        .await?;

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    runtime.output.print_human(&format_json(&res.json)?);
    Ok(())
}

async fn deny(runtime: &Runtime, proposal_id: String, reason: Option<String>) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let pid = proposal_id.trim();
    if pid.is_empty() {
        return Err(CliError::Usage("--proposal-id is required.".to_string()));
    }

    let body = if let Some(r) = reason.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
        json!({ "proposalId": pid, "reason": r })
    } else {
        json!({ "proposalId": pid })
    };

    let res = api.post_json("/v1/tools/deny", Some(body), true).await?;
    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }
    runtime.output.print_human(&format_json(&res.json)?);
    Ok(())
}

async fn list_runs(
    runtime: &Runtime,
    workspace_id: Option<String>,
    tool_name: Option<String>,
    limit: Option<u32>,
) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    let mut query: Vec<(String, String)> = Vec::new();
    if let Some(ws) = workspace_id.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
        query.push(("workspaceId".to_string(), ws));
    }
    if let Some(t) = tool_name.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
        query.push(("tool".to_string(), t));
    }
    if let Some(l) = limit {
        query.push(("limit".to_string(), l.to_string()));
    }

    let query_ref = if query.is_empty() { None } else { Some(query.as_slice()) };
    let res = api.get_json("/v1/tools/runs", query_ref, true).await?;

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let runs = res.json.get("runs").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    if runs.is_empty() {
        runtime.output.print_human("No tool runs.");
        return Ok(());
    }

    for r in runs {
        let tool = r.get("toolName").and_then(|v| v.as_str()).unwrap_or("-");
        let status = r.get("status").and_then(|v| v.as_str()).unwrap_or("-");
        let created = r.get("createdAt").and_then(|v| v.as_str()).unwrap_or("-");
        let id = r.get("id").and_then(|v| v.as_str()).unwrap_or("-");
        runtime.output.print_human(&format!("- {created} {tool} {status} ({id})"));
    }

    Ok(())
}

fn read_input_json(input: Option<String>, input_file: Option<PathBuf>, stdin: bool) -> Result<Value, CliError> {
    let sources = [
        input.as_ref().map(|_| "input"),
        input_file.as_ref().map(|_| "input_file"),
        if stdin { Some("stdin") } else { None },
    ]
    .into_iter()
    .flatten()
    .collect::<Vec<_>>();

    if sources.len() > 1 {
        return Err(CliError::Usage(
            "Pass only one of --input, --input-file, or --stdin.".to_string(),
        ));
    }

    let text = if let Some(raw) = input {
        raw
    } else if let Some(path) = input_file {
        std::fs::read_to_string(&path).map_err(|e| {
            CliError::Usage(format!("Failed to read {}: {e}", path.display()))
        })?
    } else if stdin {
        let mut buf = String::new();
        io::stdin()
            .read_to_string(&mut buf)
            .map_err(|e| CliError::Generic(format!("Failed reading stdin: {e}")))?;
        buf
    } else {
        "{}".to_string()
    };

    let trimmed = text.trim();
    if trimmed.is_empty() {
        return Ok(json!({}));
    }

    let parsed = serde_json::from_str::<Value>(trimmed).map_err(|e| {
        CliError::Usage(format!("Invalid JSON input: {e}"))
    })?;

    if !parsed.is_object() {
        return Err(CliError::Usage("Tool input must be a JSON object.".to_string()));
    }

    Ok(parsed)
}

fn prompt_approve() -> Result<bool, CliError> {
    eprint!("Approve? [y/N] ");
    let _ = io::Write::flush(&mut io::stderr());
    let mut line = String::new();
    io::stdin()
        .read_line(&mut line)
        .map_err(|e| CliError::Generic(format!("Failed reading input: {e}")))?;
    let s = line.trim().to_ascii_lowercase();
    Ok(s == "y" || s == "yes")
}

fn format_json(value: &Value) -> Result<String, CliError> {
    Ok(serde_json::to_string_pretty(value)?)
}

mod.rs

Path: starbot_tui/cli/src/commands/mod.rs

Size: 156 bytes

pub mod auth;
pub mod billing;
pub mod chat;
pub mod config;
pub mod health;
pub mod tools;
pub mod tui;
pub mod usage;
pub mod whoami;
pub mod workspaces;

whoami.rs

Path: starbot_tui/cli/src/commands/whoami.rs

Size: 1175 bytes

use crate::app::Runtime;
use crate::errors::CliError;

pub async fn handle(runtime: &Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.get_json("/v1/auth/me", None, true).await?;

    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let id = res.json.get("id").and_then(|v| v.as_str()).unwrap_or("-");
    let email = res
        .json
        .get("email")
        .and_then(|v| v.as_str())
        .unwrap_or("-");

    runtime.output.print_human(&format!("id: {id}"));
    runtime.output.print_human(&format!("email: {email}"));

    let plan_status = res
        .json
        .get("planStatus")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    let period_end = res
        .json
        .get("currentPeriodEnd")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    runtime.output.print_human(&format!("plan_status: {plan_status}"));
    runtime.output.print_human(&format!("period_end: {period_end}"));

    Ok(())
}

auth.rs

Path: starbot_tui/cli/src/commands/auth.rs

Size: 6801 bytes

use clap::Subcommand;
use serde_json::json;

use crate::app::Runtime;
use crate::config::{ensure_profile, profile_mut, save_config};
use crate::errors::CliError;

#[derive(Debug, Subcommand)]
pub enum AuthCommand {
    /// Authenticate via device code flow (QR or manual)
    Login {
        /// Paste a token directly instead of using device code flow
        #[arg(long)]
        token: Option<String>,
    },
    /// Remove bearer token from active profile
    Logout,
}

pub async fn handle(runtime: &mut Runtime, command: AuthCommand) -> Result<(), CliError> {
    match command {
        AuthCommand::Login { token } => login(runtime, token).await,
        AuthCommand::Logout => logout(runtime).await,
    }
}

async fn login(runtime: &mut Runtime, token: Option<String>) -> Result<(), CliError> {
    // If --token provided, use direct token save (legacy / CI mode)
    if let Some(t) = token {
        let t = t.trim().to_string();
        if t.is_empty() {
            return Err(CliError::Usage("Token cannot be empty.".to_string()));
        }
        return save_token(runtime, t, None);
    }

    if is_ci() {
        return Err(CliError::Usage(
            "CI mode detected. Pass `--token` explicitly.".to_string(),
        ));
    }

    // Device code flow
    device_code_flow(runtime).await
}

async fn device_code_flow(runtime: &mut Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    // 1. POST /v1/auth/device/start
    let hostname = hostname::get()
        .ok()
        .and_then(|h| h.into_string().ok())
        .unwrap_or_else(|| "unknown".to_string());

    let start_body = json!({
        "client_name": "starbott",
        "client_version": env!("CARGO_PKG_VERSION"),
        "device_meta": {
            "hostname": hostname,
            "os": std::env::consts::OS,
            "arch": std::env::consts::ARCH,
        }
    });

    let res = api
        .post_json_with_body("/v1/auth/device/start", &start_body, false)
        .await?;

    let device_code = res
        .json
        .get("deviceCode")
        .and_then(|v| v.as_str())
        .ok_or_else(|| CliError::Server("Missing deviceCode in response".to_string()))?
        .to_string();

    let user_code = res
        .json
        .get("userCode")
        .and_then(|v| v.as_str())
        .ok_or_else(|| CliError::Server("Missing userCode in response".to_string()))?
        .to_string();

    let verification_url = res
        .json
        .get("verificationUrl")
        .and_then(|v| v.as_str())
        .ok_or_else(|| CliError::Server("Missing verificationUrl in response".to_string()))?
        .to_string();

    let interval = res
        .json
        .get("interval")
        .and_then(|v| v.as_u64())
        .unwrap_or(5);

    // 2. Render QR code
    let auth_url = format!("{}?code={}", verification_url, user_code);
    render_qr(&auth_url);

    runtime.output.print_human("");
    runtime
        .output
        .print_human(&format!("  Scan the QR code or visit: {}", verification_url));
    runtime.output.print_human(&format!("  Code: {}", user_code));
    runtime.output.print_human("");
    runtime
        .output
        .print_human("  Waiting for authorization...");

    // 3. Poll loop
    let poll_body = json!({ "deviceCode": device_code });
    let max_attempts = 180 / interval; // 3 minutes max

    for _ in 0..max_attempts {
        tokio::time::sleep(std::time::Duration::from_secs(interval)).await;

        let poll_res = match api
            .post_json_with_body("/v1/auth/device/poll", &poll_body, false)
            .await
        {
            Ok(r) => r,
            Err(_) => continue, // network blip, retry
        };

        let status = poll_res
            .json
            .get("status")
            .and_then(|v| v.as_str())
            .unwrap_or("pending");

        match status {
            "authorized" => {
                let access_token = poll_res
                    .json
                    .get("accessToken")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                let refresh_token = poll_res
                    .json
                    .get("refreshToken")
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string());

                save_token(runtime, access_token, refresh_token)?;

                if runtime.output.json {
                    runtime.output.print_json(&json!({ "ok": true }))?;
                } else {
                    runtime.output.print_human("  Logged in successfully.");
                }
                return Ok(());
            }
            "expired" => {
                return Err(CliError::Auth(
                    "Device code expired. Please try again.".to_string(),
                ));
            }
            _ => {
                // pending — keep polling
            }
        }
    }

    Err(CliError::Auth(
        "Authorization timed out. Please try again.".to_string(),
    ))
}

fn render_qr(url: &str) {
    use qrcode::QrCode;

    let code = match QrCode::new(url.as_bytes()) {
        Ok(c) => c,
        Err(_) => {
            // If QR generation fails, just skip it
            return;
        }
    };

    let string = code
        .render::<char>()
        .quiet_zone(true)
        .module_dimensions(2, 1)
        .build();

    println!();
    for line in string.lines() {
        println!("  {}", line);
    }
}

fn save_token(
    runtime: &mut Runtime,
    access_token: String,
    refresh_token: Option<String>,
) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    ensure_profile(&mut runtime.config, &profile_name);
    let profile = profile_mut(&mut runtime.config, &profile_name)
        .ok_or_else(|| CliError::Generic("Failed to load active profile.".to_string()))?;
    profile.token = Some(access_token);
    if let Some(rt) = refresh_token {
        profile.refresh_token = Some(rt);
    }
    save_config(&runtime.config)?;
    Ok(())
}

async fn logout(runtime: &mut Runtime) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    ensure_profile(&mut runtime.config, &profile_name);
    let profile = profile_mut(&mut runtime.config, &profile_name)
        .ok_or_else(|| CliError::Generic("Failed to load active profile.".to_string()))?;
    profile.token = None;
    profile.refresh_token = None;
    save_config(&runtime.config)?;

    if runtime.output.json {
        runtime.output.print_json(&json!({ "ok": true }))?;
    } else {
        runtime.output.print_human("Logged out.");
    }
    Ok(())
}

fn is_ci() -> bool {
    std::env::var("CI")
        .ok()
        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
        .unwrap_or(false)
}

workspaces.rs

Path: starbot_tui/cli/src/commands/workspaces.rs

Size: 6320 bytes

use std::path::PathBuf;

use clap::Subcommand;
use serde_json::{Value, json};

use crate::app::Runtime;
use crate::errors::CliError;

#[derive(Debug, Subcommand)]
pub enum WorkspaceCommand {
    /// Create a workspace rooted at an absolute directory path.
    Create {
        /// Workspace name (defaults to the directory name)
        #[arg(long)]
        name: Option<String>,
        /// Absolute path to workspace root (defaults to current directory)
        #[arg(long)]
        root: Option<PathBuf>,
    },
    /// List workspaces visible to the current user.
    List,
    /// Set workspace permissions (owner-only).
    Permissions {
        /// Workspace id
        workspace_id: String,
        /// Target user id (defaults to self)
        #[arg(long)]
        user_id: Option<String>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_read_files: Option<bool>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_write_files: Option<bool>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_read_images: Option<bool>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_write_images: Option<bool>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_web_search: Option<bool>,
    },
}

pub async fn handle(runtime: &Runtime, command: WorkspaceCommand) -> Result<(), CliError> {
    match command {
        WorkspaceCommand::Create { name, root } => create(runtime, name, root).await,
        WorkspaceCommand::List => list(runtime).await,
        WorkspaceCommand::Permissions {
            workspace_id,
            user_id,
            can_read_files,
            can_write_files,
            can_read_images,
            can_write_images,
            can_web_search,
        } => {
            set_permissions(
                runtime,
                workspace_id,
                user_id,
                can_read_files,
                can_write_files,
                can_read_images,
                can_write_images,
                can_web_search,
            )
            .await
        }
    }
}

async fn create(runtime: &Runtime, name: Option<String>, root: Option<PathBuf>) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    let root = root.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
    let resolved = std::fs::canonicalize(&root).map_err(|e| {
        CliError::Usage(format!("Invalid workspace root {}: {e}", root.display()))
    })?;

    let default_name = resolved
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("workspace")
        .to_string();

    let name = name
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or(default_name);

    let body = json!({
        "name": name,
        "rootPath": resolved.display().to_string(),
    });

    let res = api.post_json("/v1/workspaces", Some(body), true).await?;
    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let ws = res.json.get("workspace").cloned().unwrap_or_else(|| json!({}));
    let id = ws.get("id").and_then(|v| v.as_str()).unwrap_or("-");
    let root_path = ws.get("rootPath").and_then(|v| v.as_str()).unwrap_or("-");
    runtime.output.print_human(&format!("workspace created: id={id} rootPath={root_path}"));
    Ok(())
}

async fn list(runtime: &Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.get_json("/v1/workspaces", None, true).await?;

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let items = res
        .json
        .get("workspaces")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();

    if items.is_empty() {
        runtime.output.print_human("No workspaces.");
        return Ok(());
    }

    for w in items {
        let id = w.get("id").and_then(|v| v.as_str()).unwrap_or("-");
        let name = w.get("name").and_then(|v| v.as_str()).unwrap_or("-");
        let root = w.get("rootPath").and_then(|v| v.as_str()).unwrap_or("-");
        runtime.output.print_human(&format!("- {name}  ({id})  {root}"));
    }

    Ok(())
}

async fn set_permissions(
    runtime: &Runtime,
    workspace_id: String,
    user_id: Option<String>,
    can_read_files: Option<bool>,
    can_write_files: Option<bool>,
    can_read_images: Option<bool>,
    can_write_images: Option<bool>,
    can_web_search: Option<bool>,
) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    let mut body = json!({});
    if let Some(uid) = user_id.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
        body["userId"] = json!(uid);
    }

    // Send snake_case keys (server also accepts camelCase).
    if let Some(v) = can_read_files { body["can_read_files"] = json!(v); }
    if let Some(v) = can_write_files { body["can_write_files"] = json!(v); }
    if let Some(v) = can_read_images { body["can_read_images"] = json!(v); }
    if let Some(v) = can_write_images { body["can_write_images"] = json!(v); }
    if let Some(v) = can_web_search { body["can_web_search"] = json!(v); }

    if body.as_object().map(|o| o.is_empty()).unwrap_or(true) {
        return Err(CliError::Usage(
            "No permission fields provided. Pass e.g. --can-write-files true".to_string(),
        ));
    }

    let path = format!("/v1/workspaces/{}/permissions", workspace_id.trim());
    let res = api.post_json(&path, Some(body), true).await?;

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let perm = res.json.get("permission").cloned().unwrap_or_else(|| json!({}));
    runtime.output.print_human(&format!("permissions updated: {}", summarize_perm(&perm)));
    Ok(())
}

fn summarize_perm(perm: &Value) -> String {
    let bool_field = |k: &str| perm.get(k).and_then(|v| v.as_bool()).unwrap_or(false);
    format!(
        "read_files={} write_files={} read_images={} write_images={} web_search={}",
        bool_field("can_read_files"),
        bool_field("can_write_files"),
        bool_field("can_read_images"),
        bool_field("can_write_images"),
        bool_field("can_web_search"),
    )
}

usage.rs

Path: starbot_tui/cli/src/commands/usage.rs

Size: 1732 bytes

use clap::{Args, ValueEnum};

use crate::app::Runtime;
use crate::errors::CliError;

#[derive(Debug, Clone, ValueEnum)]
pub enum UsageGroup {
    Day,
    Model,
    Provider,
}

#[derive(Debug, Args)]
pub struct UsageArgs {
    #[arg(long)]
    pub since: Option<String>,
    #[arg(long)]
    pub until: Option<String>,
    #[arg(long, value_enum)]
    pub group: Option<UsageGroup>,
}

pub async fn handle(runtime: &Runtime, args: UsageArgs) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    // v1 API currently exposes the current monthly usage window only.
    // Ignore grouping filters for now (kept for backward compat CLI flags).
    let _ = args;

    let res = api.get_json("/v1/usage/current", None, true).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let total = res
        .json
        .get("totalTokens")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    let limit = res
        .json
        .get("tokenLimit")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    let start = res
        .json
        .get("periodStart")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    let end = res
        .json
        .get("periodEnd")
        .and_then(|v| v.as_str())
        .unwrap_or("-");

    runtime.output.print_human(&format!("total_tokens: {total}"));
    runtime.output.print_human(&format!("token_limit: {limit}"));
    runtime.output.print_human(&format!("period_start: {start}"));
    runtime.output.print_human(&format!("period_end: {end}"));
    Ok(())
}

config.rs

Path: starbot_tui/cli/src/commands/config.rs

Size: 6852 bytes

use clap::{Subcommand, ValueEnum};
use serde_json::json;

use crate::app::Runtime;
use crate::config::{ensure_profile, profile_mut, profile_ref, save_config, validate_url};
use crate::errors::{CliError, redact_secret};

#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
    /// Initialize config file and profile
    Init {
        #[arg(long = "api-url")]
        api_url: Option<String>,
        #[arg(long)]
        token: Option<String>,
    },
    /// Read a config key from the active profile
    Get {
        key: ConfigKey,
        #[arg(long)]
        show_token: bool,
    },
    /// Set a config key on the active profile
    Set { key: ConfigKey, value: String },
    /// List all profiles
    Profiles,
    /// Switch active profile
    Use { profile: String },
}

#[derive(Debug, Clone, ValueEnum)]
pub enum ConfigKey {
    #[value(name = "apiUrl")]
    ApiUrl,
    #[value(name = "token")]
    Token,
}

pub async fn handle(runtime: &mut Runtime, command: ConfigCommand) -> Result<(), CliError> {
    match command {
        ConfigCommand::Init { api_url, token } => init(runtime, api_url, token).await,
        ConfigCommand::Get { key, show_token } => get(runtime, key, show_token).await,
        ConfigCommand::Set { key, value } => set(runtime, key, value).await,
        ConfigCommand::Profiles => profiles(runtime).await,
        ConfigCommand::Use { profile } => use_profile(runtime, profile).await,
    }
}

async fn init(
    runtime: &mut Runtime,
    api_url: Option<String>,
    token: Option<String>,
) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    ensure_profile(&mut runtime.config, &profile_name);
    if let Some(profile) = profile_mut(&mut runtime.config, &profile_name) {
        if let Some(url) = api_url {
            validate_url(&url)?;
            profile.api_url = url;
        }

        if let Some(value) = token {
            profile.token = Some(value);
        } else if !is_ci() && !runtime.output.json && !runtime.output.quiet {
            let maybe_token = rpassword::prompt_password("Token (optional, Enter to skip): ")
                .map_err(|e| CliError::Generic(format!("Failed reading token: {e}")))?;
            if !maybe_token.trim().is_empty() {
                profile.token = Some(maybe_token.trim().to_string());
            }
        }
    }

    runtime.config.profile = profile_name;
    let path = save_config(&runtime.config)?;
    runtime.config_path = path.clone();

    if runtime.output.json {
        runtime
            .output
            .print_json(&json!({ "ok": true, "path": path }))?;
    } else {
        runtime
            .output
            .print_human(&format!("Config initialized: {}", path.display()));
    }
    Ok(())
}

async fn get(runtime: &mut Runtime, key: ConfigKey, show_token: bool) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    let profile = profile_ref(&runtime.config, &profile_name).ok_or_else(|| {
        CliError::Usage(format!(
            "Profile '{profile_name}' not found. Run `starbott config init` first."
        ))
    })?;

    match key {
        ConfigKey::ApiUrl => {
            if runtime.output.json {
                runtime.output.print_json(&json!({
                    "key": "apiUrl",
                    "value": profile.api_url
                }))?;
            } else {
                runtime.output.print_human(&profile.api_url);
            }
        }
        ConfigKey::Token => {
            let resolved = runtime.resolved_token();
            let display = resolved.as_deref().map(|t| {
                if show_token {
                    t.to_string()
                } else {
                    redact_secret(t)
                }
            });

            if runtime.output.json {
                runtime.output.print_json(&json!({
                    "key": "token",
                    "value": display
                }))?;
            } else if let Some(v) = display {
                runtime.output.print_human(&v);
            } else {
                runtime.output.print_human("(not set)");
            }
        }
    }

    Ok(())
}

async fn set(runtime: &mut Runtime, key: ConfigKey, value: String) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    ensure_profile(&mut runtime.config, &profile_name);
    let profile = profile_mut(&mut runtime.config, &profile_name).ok_or_else(|| {
        CliError::Generic(format!(
            "Failed to resolve profile '{profile_name}' while setting config."
        ))
    })?;

    match key {
        ConfigKey::ApiUrl => {
            validate_url(&value)?;
            profile.api_url = value;
        }
        ConfigKey::Token => {
            profile.token = Some(value);
        }
    }

    let path = save_config(&runtime.config)?;
    runtime.config_path = path;

    if runtime.output.json {
        runtime.output.print_json(&json!({ "ok": true }))?;
    } else {
        runtime.output.print_human("Config updated.");
    }

    Ok(())
}

async fn profiles(runtime: &mut Runtime) -> Result<(), CliError> {
    let active = runtime.active_profile();
    let mut names: Vec<String> = runtime.config.profiles.keys().cloned().collect();
    names.sort();

    if runtime.output.json {
        let payload = names
            .iter()
            .map(|name| {
                let profile = runtime.config.profiles.get(name);
                json!({
                    "name": name,
                    "active": name == &active,
                    "apiUrl": profile.map(|p| p.api_url.clone()).unwrap_or_default(),
                    "hasToken": profile
                        .and_then(|p| p.token.as_ref())
                        .map(|t| !t.is_empty())
                        .unwrap_or(false)
                })
            })
            .collect::<Vec<_>>();
        runtime.output.print_json(&json!({ "profiles": payload }))?;
        return Ok(());
    }

    for name in names {
        let marker = if name == active { "*" } else { " " };
        runtime.output.print_human(&format!("{marker} {name}"));
    }
    Ok(())
}

async fn use_profile(runtime: &mut Runtime, profile_name: String) -> Result<(), CliError> {
    ensure_profile(&mut runtime.config, &profile_name);
    runtime.config.profile = profile_name.clone();
    let path = save_config(&runtime.config)?;
    runtime.config_path = path;

    if runtime.output.json {
        runtime
            .output
            .print_json(&json!({ "ok": true, "profile": profile_name }))?;
    } else {
        runtime
            .output
            .print_human(&format!("Active profile: {profile_name}"));
    }

    Ok(())
}

fn is_ci() -> bool {
    std::env::var("CI")
        .ok()
        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
        .unwrap_or(false)
}

health.rs

Path: starbot_tui/cli/src/commands/health.rs

Size: 1322 bytes

use crate::app::Runtime;
use crate::errors::CliError;

pub async fn handle(runtime: &Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.get_json("/health", None, false).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let ok = res
        .json
        .get("ok")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let version = res
        .json
        .get("version")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    let inference = res
        .json
        .get("inference")
        .and_then(|v| v.as_str())
        .unwrap_or("-");

    runtime.output.print_human(&format!("ok: {ok}"));
    runtime.output.print_human(&format!("version: {version}"));
    runtime
        .output
        .print_human(&format!("inference: {inference}"));

    if let Some(providers) = res.json.get("providers").and_then(|v| v.as_object()) {
        for (name, status) in providers {
            runtime.output.print_human(&format!(
                "provider.{name}: {}",
                status.as_str().unwrap_or("-")
            ));
        }
    }

    Ok(())
}

billing.rs

Path: starbot_tui/cli/src/commands/billing.rs

Size: 2295 bytes

use clap::{Args, Subcommand};
use serde_json::json;

use crate::app::Runtime;
use crate::errors::CliError;

#[derive(Debug, Subcommand)]
pub enum BillingCommand {
    /// Show subscription and plan status
    Status,
    /// Create a billing portal session URL
    Portal(PortalArgs),
}

#[derive(Debug, Args)]
pub struct PortalArgs {
    #[arg(long)]
    pub open: bool,
}

pub async fn handle(runtime: &Runtime, command: BillingCommand) -> Result<(), CliError> {
    match command {
        BillingCommand::Status => status(runtime).await,
        BillingCommand::Portal(args) => portal(runtime, args).await,
    }
}

async fn status(runtime: &Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.get_json("/v1/auth/me", None, true).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let plan_status = res
        .json
        .get("planStatus")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    let renews = res
        .json
        .get("currentPeriodEnd")
        .and_then(|v| v.as_str())
        .unwrap_or("-");

    runtime.output.print_human(&format!("plan_status: {plan_status}"));
    runtime.output.print_human(&format!("period_end: {renews}"));
    Ok(())
}

async fn portal(runtime: &Runtime, args: PortalArgs) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.post_json("/v1/billing/portal-session", None, true).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    let url = res
        .json
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or_else(|| {
            CliError::Server("Billing portal URL was not returned by the server.".to_string())
        })?;

    if runtime.output.json {
        runtime.output.print_json(&json!({ "url": url }))?;
    } else {
        runtime.output.print_human(url);
    }

    if args.open {
        open::that(url).map_err(|e| {
            CliError::Generic(format!("Failed to open browser for portal URL: {e}"))
        })?;
    }

    Ok(())
}

tui.rs

Path: starbot_tui/cli/src/commands/tui.rs

Size: 89796 bytes

use std::io;
use std::time::{Duration, Instant};

use clap::Args;
use crossterm::cursor::Show;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::block::{Position, Title};
use ratatui::widgets::{
    Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap,
};
use ratatui::{Frame, Terminal};
use serde_json::{Value, json};
use tokio::sync::mpsc;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

use crate::api::{ApiClient, ApiResponse};
use crate::app::Runtime;
use crate::config::{CliConfig, profile_mut, profile_ref, save_config};
use crate::cute::{CuteMode, load_cute_mode};
use crate::errors::CliError;

// PHASE 3: Import handler modules and response parsing
use crate::parse::response::{extract_reply, extract_provider_model, extract_usage_line};
use crate::tui::types::*;
use crate::tui::handlers::{handle_event, handle_tui_msg};
use crate::tui::handlers::async_ops::{
    spawn_models_fetch, spawn_health_fetch, spawn_workspaces_fetch,
    spawn_chat_request_stream, spawn_tool_propose,
};

#[derive(Debug, Args)]
pub struct TuiArgs {
    /// Model selector. Examples: "vertex:gemini-3-flash-preview" or "auto"
    #[arg(short = 'm', long = "model")]
    pub model: Option<String>,
}
struct TerminalGuard;

impl TerminalGuard {
    fn enter() -> Result<Self, CliError> {
        enable_raw_mode()
            .map_err(|e| CliError::Generic(format!("Failed to enable raw mode: {e}")))?;
        let mut stdout = io::stdout();
        execute!(stdout, EnterAlternateScreen)
            .map_err(|e| CliError::Generic(format!("Failed to enter alternate screen: {e}")))?;
        Ok(Self)
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let mut stdout = io::stdout();
        let _ = execute!(stdout, Show, LeaveAlternateScreen);
    }
}

pub async fn handle(runtime: &Runtime, args: TuiArgs) -> Result<(), CliError> {
    if runtime.output.json {
        return Err(CliError::Usage(
            "`--json` is not supported for `starbott tui`.".to_string(),
        ));
    }

    let api = runtime.api_client()?;
    let api_url = runtime.resolved_api_url()?;
    let profile = runtime.active_profile();
    let config = runtime.config.clone();
    let token_present = runtime.resolved_token().is_some();
    let cute = load_cute_mode();
    let selected_workspace_id = profile_ref(&config, &profile).and_then(|p| p.workspace_id.clone());

    let (initial_provider, initial_model) = parse_model_selector(args.model.as_deref());

    let guard = TerminalGuard::enter()?;

    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)
        .map_err(|e| CliError::Generic(format!("Failed to init terminal: {e}")))?;
    terminal
        .clear()
        .map_err(|e| CliError::Generic(format!("Failed to clear terminal: {e}")))?;
    terminal
        .hide_cursor()
        .map_err(|e| CliError::Generic(format!("Failed to hide cursor: {e}")))?;

    let mut app = App {
        mode: Mode::Chat,
        should_quit: false,
        api_url,
        config,
        profile,
        token_present,
        cute,
        rng: seed_rng(),
        last_phrase: None,
        success_count: 0,
        lane: None,
        hints: ProviderHints {
            vertex_ok: None,
            azure_present: false,
            cf_present: false,
        },
        spinner_step: 0,
        spinner_last: Instant::now(),
        bg_tasks: 0,
        messages: vec![ChatMsg {
            role: ChatRole::System,
            content: "Starbot TUI. Enter to send. F2 models. F3 workspace. F1 help. Esc quit.".to_string(),
            sendable: false,
        }],
        input: Vec::new(),
        cursor: 0,
        waiting: false,
        status: startup_status(cute).to_string(),
        last_request_id: None,
        last_elapsed_ms: None,
        last_provider: None,
        last_model: None,
        last_usage: None,
        activity_lines: Vec::new(),
        current_file: None,
        auto_edits: false,
        working_dir: std::env::current_dir()
            .map(|p| p.display().to_string())
            .unwrap_or_else(|_| ".".to_string()),
        model_options: default_model_options(),
        model_state: ListState::default(),
        selected_provider: initial_provider,
        selected_model: initial_model,
        workspace_options: Vec::new(),
        workspace_state: ListState::default(),
        selected_workspace_id,
        selected_workspace_name: None,
        pending_workspace_retry: false,
        thread_options: Vec::new(),
        thread_state: ListState::default(),
        active_thread_id: None,
        active_thread_title: None,
        memory_items: Vec::new(),
        memory_state: ListState::default(),
        memory_settings: MemorySettings::default(),
        memory_enabled: true,
        choice_prompt: None,
        choice_state: ListState::default(),
        text_prompt: None,
        scroll_from_bottom: 0,
        show_debug: false,
        pending_tool: None,
        tool_approval_history: Vec::new(),
    };

    app.model_state.select(Some(0));
    app.workspace_state.select(Some(0));

    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMsg>();
    app.bg_tasks = app.bg_tasks.saturating_add(2);
    spawn_models_fetch(api.clone(), tx.clone());
    spawn_health_fetch(api.clone(), tx.clone());
    if app.token_present {
        app.bg_tasks = app.bg_tasks.saturating_add(1);
        spawn_workspaces_fetch(api.clone(), tx.clone());
    }

    loop {
        update_spinner(&mut app);
        terminal
            .draw(|f| ui(f, &mut app))
            .map_err(|e| CliError::Generic(format!("Failed to draw: {e}")))?;

        if app.should_quit {
            break;
        }

        while let Ok(msg) = rx.try_recv() {
            handle_tui_msg(&api, &tx, &mut app, msg);
        }

        // Don't redraw at full speed when idle; keep a calmer cadence.
        let poll_ms = if app.waiting || app.bg_tasks > 0 || app.mode != Mode::Chat {
            50
        } else {
            120
        };
        if crossterm::event::poll(Duration::from_millis(poll_ms))
            .map_err(|e| CliError::Generic(format!("Event poll failed: {e}")))?
        {
            let event = crossterm::event::read()
                .map_err(|e| CliError::Generic(format!("Event read failed: {e}")))?;
            if let Err(err) = handle_event(&api, &tx, &mut app, event) {
                app.messages.push(ChatMsg {
                    role: ChatRole::System,
                    content: format!("Error: {err}"),
                    sendable: false,
                });
                app.status = "Error".to_string();
            }
        }
    }

    terminal
        .show_cursor()
        .map_err(|e| CliError::Generic(format!("Failed to restore cursor: {e}")))?;
    drop(guard);
    Ok(())
}

fn default_model_options() -> Vec<ModelOption> {
    vec![
        ModelOption {
            provider: "auto".to_string(),
            model: None,
            label: "Auto".to_string(),
        },
        ModelOption {
            provider: "kimi".to_string(),
            model: None,
            label: "Kimi K2".to_string(),
        },
        ModelOption {
            provider: "vertex".to_string(),
            model: Some("gemini-3-flash-preview".to_string()),
            label: "Gemini 3 Flash Preview".to_string(),
        },
    ]
}

fn parse_model_selector(selector: Option<&str>) -> (String, Option<String>) {
    let Some(raw) = selector else {
        return ("auto".to_string(), None);
    };
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return ("auto".to_string(), None);
    }

    if let Some((provider, model)) = trimmed.split_once(':') {
        let p = provider.trim().to_ascii_lowercase();
        let m = model.trim().to_string();
        return (p, if m.is_empty() { None } else { Some(m) });
    }

    let lower = trimmed.to_ascii_lowercase();
    if matches!(
        lower.as_str(),
        "auto" | "kimi" | "gemini" | "vertex" | "cloudflare" | "azure" | "openai"
    ) {
        return (lower, None);
    }

    // Convenient default for "gemini-*" model ids.
    ("vertex".to_string(), Some(trimmed.to_string()))
}

pub fn parse_model_options(payload: &Value) -> Option<Vec<ModelOption>> {
    let providers = payload.get("providers")?.as_array()?;
    let mut options = Vec::new();

    for p in providers {
        let label = p
            .get("label")
            .and_then(|v| v.as_str())
            .or_else(|| p.get("id").and_then(|v| v.as_str()))
            .unwrap_or("unknown")
            .to_string();

        let provider = p
            .get("provider")
            .and_then(|v| v.as_str())
            .or_else(|| p.get("id").and_then(|v| v.as_str()))
            .unwrap_or("auto")
            .to_string();

        let model = p
            .get("model")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());

        // Normalize provider for "auto"/"kimi" entries where provider is absent and "id" is used.
        let provider = if provider.contains(':') {
            provider.split(':').next().unwrap_or("auto").to_string()
        } else {
            provider
        };

        options.push(ModelOption {
            provider,
            model,
            label,
        });
    }

    Some(options)
}

pub fn parse_workspace_options(payload: &Value) -> Option<Vec<WorkspaceOption>> {
    let workspaces = payload.get("workspaces")?.as_array()?;
    let mut options = Vec::new();

    for w in workspaces {
        let id = w.get("id").and_then(|v| v.as_str()).map(str::trim).unwrap_or("");
        if id.is_empty() {
            continue;
        }
        let name = w
            .get("name")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .unwrap_or("workspace")
            .to_string();
        let root_path = w
            .get("rootPath")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());
        let archived = w.get("archived").and_then(|v| v.as_bool()).unwrap_or(false);
        let last_used_at = w
            .get("lastUsedAt")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());

        options.push(WorkspaceOption {
            id: id.to_string(),
            name,
            root_path,
            archived,
            last_used_at,
        });
    }

    Some(options)
}

pub fn parse_thread_options(payload: &Value) -> Option<Vec<ThreadOption>> {
    let threads = payload.get("threads")?.as_array()?;
    let mut options = Vec::new();

    for t in threads {
        let id = t.get("id").and_then(|v| v.as_str()).map(str::trim).unwrap_or("");
        if id.is_empty() {
            continue;
        }
        let title = t
            .get("title")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .unwrap_or("Untitled")
            .to_string();
        let mode = t
            .get("mode")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());
        let last_message_at = t
            .get("lastMessageAt")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());
        let is_pinned = t.get("isPinned").and_then(|v| v.as_bool()).unwrap_or(false);
        let message_count = t.get("_count")
            .and_then(|v| v.get("messages"))
            .and_then(|v| v.as_u64())
            .unwrap_or(0) as usize;

        options.push(ThreadOption {
            id: id.to_string(),
            title,
            mode,
            last_message_at,
            is_pinned,
            message_count,
        });
    }

    Some(options)
}

pub fn parse_memory_items(payload: &Value) -> Option<Vec<MemoryItem>> {
    let items = payload.get("items")?.as_array()?;
    let mut options = Vec::new();

    for item in items {
        let id = item.get("id").and_then(|v| v.as_str()).map(str::trim).unwrap_or("");
        if id.is_empty() {
            continue;
        }

        let scope = item.get("scope").and_then(|v| v.as_str()).unwrap_or("global").to_string();
        let project_id = item.get("projectId").and_then(|v| v.as_str()).map(|s| s.to_string());
        let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("fact").to_string();
        let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
        let tags = item.get("tags")
            .and_then(|v| v.as_array())
            .map(|arr| arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect())
            .unwrap_or_default();
        let salience = item.get("salience").and_then(|v| v.as_f64()).unwrap_or(0.5);
        let confidence = item.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.7);
        let source = item.get("source").and_then(|v| v.as_str()).unwrap_or("manual").to_string();
        let enabled = item.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
        let created_at = item.get("createdAt").and_then(|v| v.as_str()).unwrap_or("").to_string();
        let updated_at = item.get("updatedAt").and_then(|v| v.as_str()).unwrap_or("").to_string();

        options.push(MemoryItem {
            id: id.to_string(),
            scope,
            project_id,
            item_type,
            content,
            tags,
            salience,
            confidence,
            source,
            enabled,
            created_at,
            updated_at,
        });
    }

    Some(options)
}

pub fn parse_memory_settings(payload: &Value) -> Option<MemorySettings> {
    Some(MemorySettings {
        enabled: payload.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
        allow_auto_capture: payload.get("allowAutoCapture").and_then(|v| v.as_bool()).unwrap_or(true),
        max_context_tokens: payload.get("maxContextTokens").and_then(|v| v.as_u64()).unwrap_or(600) as usize,
        max_items_injected: payload.get("maxItemsInjected").and_then(|v| v.as_u64()).unwrap_or(12) as usize,
        include_global: payload.get("includeGlobal").and_then(|v| v.as_bool()).unwrap_or(true),
        include_project: payload.get("includeProject").and_then(|v| v.as_bool()).unwrap_or(true),
    })
}

pub fn parse_choice_prompt(payload: &Value) -> Option<ChoicePrompt> {
    let cp = payload
        .get("choicePrompt")
        .or_else(|| payload.get("choice_prompt"))?;
    let title = cp
        .get("title")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or("Choose an option")
        .to_string();
    let id = cp
        .get("id")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or("choice")
        .to_string();
    let hint = cp
        .get("hint")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .unwrap_or("")
        .to_string();
    let allow_custom = cp
        .get("allowCustom")
        .or_else(|| cp.get("allow_custom"))
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let custom_placeholder = cp
        .get("customPlaceholder")
        .or_else(|| cp.get("custom_placeholder"))
        .and_then(|v| v.as_str())
        .map(str::trim)
        .unwrap_or("")
        .to_string();

    let options_v = cp.get("options")?.as_array()?;
    let mut options: Vec<ChoiceOption> = Vec::new();

    for o in options_v {
        let oid = o.get("id").and_then(|v| v.as_str()).map(str::trim).unwrap_or("");
        let label = o
            .get("label")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .unwrap_or(oid)
            .to_string();
        if label.is_empty() {
            continue;
        }
        let desc = o
            .get("description")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .unwrap_or("")
            .to_string();

        let action = o.get("action")?;
        let typ = action.get("type").and_then(|v| v.as_str()).unwrap_or("");

        let parsed_action = match typ {
            "set_workspace" => {
                let ws = action
                    .get("workspaceId")
                    .or_else(|| action.get("workspace_id"))
                    .and_then(|v| v.as_str())
                    .map(str::trim)
                    .unwrap_or("");
                if ws.is_empty() {
                    continue;
                }
                ChoiceAction::SetWorkspace {
                    workspace_id: ws.to_string(),
                }
            }
            "tool" => {
                let tool = action
                    .get("toolName")
                    .or_else(|| action.get("tool_name"))
                    .or_else(|| action.get("tool"))
                    .and_then(|v| v.as_str())
                    .map(str::trim)
                    .unwrap_or("");
                if tool.is_empty() {
                    continue;
                }
                let input = action.get("input").cloned().unwrap_or_else(|| json!({}));
                ChoiceAction::Tool {
                    tool_name: tool.to_string(),
                    input,
                }
            }
            "input" => {
                let prompt = action
                    .get("prompt")
                    .and_then(|v| v.as_str())
                    .map(str::trim)
                    .unwrap_or("Input");
                ChoiceAction::Input {
                    prompt: prompt.to_string(),
                }
            }
            "send_message" => {
                let text = action
                    .get("text")
                    .and_then(|v| v.as_str())
                    .map(str::trim)
                    .unwrap_or("");
                if text.is_empty() {
                    continue;
                }
                ChoiceAction::SendMessage {
                    text: text.to_string(),
                }
            }
            _ => {
                continue;
            }
        };

        options.push(ChoiceOption {
            id: oid.to_string(),
            label,
            description: desc,
            action: parsed_action,
        });
    }

    if options.is_empty() {
        return None;
    }

    Some(ChoicePrompt {
        id,
        title,
        hint,
        allow_custom,
        custom_placeholder,
        options,
    })
}

pub fn find_selected_model_index(
    options: &[ModelOption],
    provider: &str,
    model: Option<&str>,
) -> Option<usize> {
    options.iter().position(|o| {
        o.provider == provider
            && match (o.model.as_deref(), model) {
                (None, None) => true,
                (Some(a), Some(b)) => a == b,
                _ => false,
            }
    })
}

fn ui(f: &mut Frame<'_>, app: &mut App) {
    let size = f.area();

    // Layout: file panel | main list | input | info panel
    // If a file is active, show it at top; otherwise give all space to the main list.
    let has_file = app.current_file.is_some();
    let file_height = if has_file { 6u16 } else { 0 };

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(file_height),  // file being edited
            Constraint::Min(1),              // main list (chat)
            Constraint::Length(3),           // input prompt
            Constraint::Length(4),           // info panel (dir/model/auto-edits/status)
        ])
        .split(size);

    // ── File panel (top) ──
    if has_file {
        render_file_panel(f, app, layout[0]);
    }

    // ── Main list (chat) ──
    let (chat_area, debug_area) = if app.show_debug {
        let cols = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(72), Constraint::Percentage(28)])
            .split(layout[1]); // main list row
        (cols[0], Some(cols[1]))
    } else {
        (layout[1], None)
    };

    let chat = render_chat(app, chat_area);
    f.render_widget(chat, chat_area);

    if let Some(area) = debug_area {
        let dbg = render_debug(app);
        f.render_widget(dbg, area);
    }

    // ── Input prompt (enclosed with heart prefix) ──
    let input = render_input(app);
    f.render_widget(input, layout[2]);

    if app.mode == Mode::Chat {
        let prompt_cols = input_prompt_len(app) as u16;
        let x = layout[2]
            .x
            .saturating_add(1)
            .saturating_add(prompt_cols)
            .saturating_add(app.cursor as u16);
        let y = layout[2].y.saturating_add(1);
        f.set_cursor_position((x.min(layout[2].x + layout[2].width - 2), y));
    }

    // ── Info panel (bottom, below the input) ──
    render_info_panel(f, app, layout[3]);

    // ── Popups ──
    match app.mode {
        Mode::ModelPicker => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_model_picker_popup(f, app, area);
        }
        Mode::WorkspacePicker => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_workspace_picker_popup(f, app, area);
        }
        Mode::ThreadPicker => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_thread_picker_popup(f, app, area);
        }
        Mode::MemoryPanel => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_memory_panel_popup(f, app, area);
        }
        Mode::ChoiceModal => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_choice_modal_popup(f, app, area);
        }
        Mode::TextPromptModal => {
            let area = centered_rect(70, 40, size);
            f.render_widget(Clear, area);
            render_text_prompt_popup(f, app, area);
        }
        Mode::Help => {
            let area = centered_rect(70, 60, size);
            f.render_widget(Clear, area);
            let popup = render_help(app);
            f.render_widget(popup, area);
        }
        Mode::ToolCard => {
            let area = centered_rect(70, 50, size);
            f.render_widget(Clear, area);
            let popup = render_tool_card(app);
            f.render_widget(popup, area);
        }
        Mode::Chat => {}
    }
}

fn render_file_panel(f: &mut Frame<'_>, app: &App, area: Rect) {
    let file_name = app.current_file.as_deref().unwrap_or("");
    let title = format!(" {} ", file_name);

    let mut lines = Vec::new();
    // Show last few activity lines as context for what's happening to this file
    let start = app.activity_lines.len().saturating_sub(4);
    for line in &app.activity_lines[start..] {
        lines.push(Line::from(vec![Span::styled(
            format!("  {line}"),
            Style::default().fg(c_muted()),
        )]));
    }
    if lines.is_empty() {
        lines.push(Line::from(vec![Span::styled(
            "  (no activity)",
            Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
        )]));
    }

    let widget = Paragraph::new(Text::from(lines))
        .block({
            let mut block = Block::default()
                .borders(Borders::ALL)
                .title(title);
            if app.cute != CuteMode::Off {
                block = block.border_style(Style::default().fg(c_sparkle()));
            }
            block
        })
        .wrap(Wrap { trim: false });
    f.render_widget(widget, area);
}

fn render_info_bar(f: &mut Frame<'_>, app: &App, area: Rect) {
    let auto_label = if app.auto_edits { "auto-edits: on" } else { "auto-edits: off" };

    let dir_display = if app.working_dir.len() > 30 {
        let truncated = &app.working_dir[app.working_dir.len().saturating_sub(28)..];
        format!("..{truncated}")
    } else {
        app.working_dir.clone()
    };

    let model_display = match (&app.last_provider, &app.last_model) {
        (Some(p), Some(m)) => format!("{p}:{m}"),
        (Some(p), None) => p.clone(),
        _ => "none".to_string(),
    };

    let spans = if app.cute == CuteMode::Off {
        vec![
            Span::raw(format!(
                " {auto_label}  |  dir: {dir_display}  |  model: {model_display}  |  {}", app.status
            )),
        ]
    } else {
        vec![
            Span::styled(" ♡ ", Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
            Span::styled(auto_label, Style::default().fg(c_muted())),
            Span::styled("  |  ", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
            Span::styled(format!("dir: {dir_display}"), Style::default().fg(c_muted())),
            Span::styled("  |  ", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
            Span::styled(format!("model: {model_display}"), Style::default().fg(c_sparkle())),
            Span::styled("  |  ", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
            Span::styled(app.status.clone(), Style::default().fg(c_muted())),
        ]
    };

    let bar = Paragraph::new(Line::from(spans))
        .style(Style::default().bg(Color::Rgb(30, 30, 40)).fg(Color::White));
    f.render_widget(bar, area);
}

fn render_info_panel(f: &mut Frame<'_>, app: &App, area: Rect) {
    let auto_label = if app.auto_edits { "auto-edits: on" } else { "auto-edits: off" };

    let ws_display = match (&app.selected_workspace_name, &app.selected_workspace_id) {
        (Some(name), _) if !name.trim().is_empty() => name.trim().to_string(),
        (_, Some(id)) if !id.trim().is_empty() => id.trim().to_string(),
        _ => "none".to_string(),
    };

    let last_model_display = match (&app.last_provider, &app.last_model) {
        (Some(p), Some(m)) => format!("{p}:{m}"),
        (Some(p), None) => p.clone(),
        _ => "none".to_string(),
    };

    let selected_display = format!(
        "{}{}",
        app.selected_provider,
        app.selected_model
            .as_deref()
            .map(|m| format!(":{m}"))
            .unwrap_or_default()
    );
    let show_last_used_model = app.selected_provider == "auto";

    let status_line = if app.cute == CuteMode::Off {
        Line::from(format!(" {}", app.status))
    } else {
        let mut spans: Vec<Span<'static>> = Vec::new();
        spans.push(Span::raw(" "));
        spans.push(Span::styled(
            "♡",
            Style::default().fg(c_heart()).add_modifier(Modifier::BOLD),
        ));
        spans.push(Span::raw("  "));
        spans.extend(lane_spans(app.lane));
        spans.push(Span::raw("  "));
        spans.push(Span::styled(app.status.clone(), Style::default().fg(c_muted())));
        Line::from(spans)
    };

    let (line2_spans, line3_spans) = if app.cute == CuteMode::Off {
        let line3 = if show_last_used_model {
            format!(" selected: {selected_display}  |  last: {last_model_display} ")
        } else {
            format!(" selected: {selected_display} ")
        };
        (
            vec![Span::raw(format!(" {auto_label}  |  workspace: {ws_display} "))],
            vec![Span::raw(line3)],
        )
    } else {
        let line2_spans = vec![
            Span::styled(auto_label, Style::default().fg(c_muted())),
            Span::styled(
                "  |  ",
                Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
            ),
            Span::styled("workspace: ", Style::default().fg(c_muted())),
            Span::styled(ws_display, Style::default().fg(c_sparkle())),
        ];

        let mut line3_spans = vec![
            Span::raw("   "),
            Span::styled("selected: ", Style::default().fg(c_muted())),
            Span::styled(selected_display, Style::default().fg(c_sparkle())),
        ];
        if show_last_used_model {
            line3_spans.push(Span::styled(
                "  |  ",
                Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
            ));
            line3_spans.push(Span::styled("last: ", Style::default().fg(c_muted())));
            line3_spans.push(Span::styled(last_model_display, Style::default().fg(c_muted())));
        }
        (line2_spans, line3_spans)
    };

    let req = app.last_request_id.as_deref().unwrap_or("-");
    let elapsed = app
        .last_elapsed_ms
        .map(|v| format!("{v}ms"))
        .unwrap_or_else(|| "-".to_string());
    let usage_right = app
        .last_usage
        .as_deref()
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
        .unwrap_or("");

    let total_width = area.width.max(1) as usize;

    // Right column: req/elapsed above usage, both aligned to the right edge.
    let req_elapsed = format!("req: {req} | elapsed: {elapsed}");
    let req_elapsed_right = if app.cute == CuteMode::Off {
        vec![Span::raw(req_elapsed)]
    } else {
        vec![Span::styled(req_elapsed, Style::default().fg(c_muted()))]
    };

    let usage_right = if usage_right.is_empty() {
        Vec::new()
    } else if app.cute == CuteMode::Off {
        vec![Span::raw(usage_right.to_string())]
    } else {
        vec![Span::styled(
            usage_right.to_string(),
            Style::default().fg(c_muted()),
        )]
    };

    let line2 = compose_lr_line(total_width, &line2_spans, &req_elapsed_right);
    let line3 = compose_lr_line(total_width, &line3_spans, &usage_right);

    // Shift everything down one row.
    let panel = Paragraph::new(Text::from(vec![Line::from(""), status_line, line2, line3]))
        .style(Style::default().bg(Color::Rgb(30, 30, 40)).fg(Color::White))
        .wrap(Wrap { trim: false });
    f.render_widget(panel, area);
}

fn render_header(f: &mut Frame<'_>, app: &App, area: Rect) {
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
        .split(area);

    let base = Style::default().fg(Color::Black).bg(Color::White);
    let left = Paragraph::new(header_left_line(app)).style(base);

    let sel = format!(
        "{}{}",
        app.selected_provider,
        app.selected_model
            .as_deref()
            .map(|m| format!(":{m}"))
            .unwrap_or_default()
    );
    let token = if app.token_present {
        "token=yes"
    } else {
        "token=no"
    };
    let mut right_spans = provider_health_spans(app.cute, app.hints);
    right_spans.push(Span::raw(format!("  api={}  profile={}  {}  {sel} ", app.api_url, app.profile, token)));
    let right = Paragraph::new(Line::from(right_spans))
        .style(base)
        .alignment(Alignment::Right);

    f.render_widget(left, cols[0]);
    f.render_widget(right, cols[1]);
}

fn render_chat(app: &App, area: Rect) -> Paragraph<'static> {
    let width = area.width.saturating_sub(2).max(1) as usize;
    let height = area.height.saturating_sub(2).max(1) as usize;

    let all_lines = build_chat_lines(
        &app.messages,
        width,
        app.cute,
        app.spinner_step,
        app.spinner_last,
    );
    let total = all_lines.len();
    let max_scroll = total.saturating_sub(height);
    let scroll = app.scroll_from_bottom.min(max_scroll);
    let top = max_scroll.saturating_sub(scroll);
    let end = (top + height).min(total);

    let visible = &all_lines[top..end];
    let text = Text::from(visible.to_vec());

    let base_style = if app.cute == CuteMode::Off {
        Style::default()
    } else {
        // Match the logo "negative space" and the bottom panels.
        Style::default()
            .bg(Color::Rgb(30, 30, 40))
            .fg(Color::White)
    };

    Paragraph::new(text)
        .block(
            {
                let left_title = "Chat (PgUp/PgDn scroll, Ctrl+D debug)";
                let title_width = area.width.saturating_sub(2).max(1) as usize;
                let left_w = left_title.width();
                let label = "dir: ";
                // Add a little spacing so the right title doesn't crowd the left title.
                let max_dir_w = title_width.saturating_sub(left_w + label.width() + 4);
                let dir_display = tail_truncate_to_width(&app.working_dir, max_dir_w);

                let mut block = Block::default()
                    .borders(Borders::ALL)
                    .border_type(BorderType::Rounded)
                    .title(left_title);

                // Put cwd on the top-right of the chat border.
                let dir_span = if app.cute == CuteMode::Off {
                    Span::raw(format!(" {label}{dir_display} "))
                } else {
                    Span::styled(
                        format!(" {label}{dir_display} "),
                        Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
                    )
                };
                block = block.title(
                    Title::from(Line::from(vec![dir_span]))
                        .alignment(Alignment::Right)
                        .position(Position::Top),
                );
                if app.cute != CuteMode::Off && (app.waiting || app.bg_tasks > 0) {
                    block = block.title(
                        Title::from(Line::from(border_blink_spans(
                            app.spinner_step,
                            area.width,
                        )))
                            .alignment(Alignment::Left)
                            .position(Position::Bottom),
                    );
                }
                if app.cute != CuteMode::Off {
                    block = block.border_style(Style::default().fg(c_sparkle()));
                }
                block
            },
        )
        .style(base_style)
        .wrap(Wrap { trim: false })
}

fn render_debug(app: &App) -> Paragraph<'static> {
    let mut lines = Vec::new();
    lines.push(Line::from(vec![Span::styled(
        "Debug",
        Style::default().add_modifier(Modifier::BOLD),
    )]));
    lines.push(Line::from(format!("waiting={}", app.waiting)));
    lines.push(Line::from(format!(
        "selected={}",
        format!(
            "{}{}",
            app.selected_provider,
            app.selected_model
                .as_deref()
                .map(|m| format!(":{m}"))
                .unwrap_or_default()
        )
    )));
    lines.push(Line::from(format!(
        "req_id={}",
        app.last_request_id.as_deref().unwrap_or("-")
    )));
    lines.push(Line::from(format!(
        "elapsed_ms={}",
        app.last_elapsed_ms
            .map(|v| v.to_string())
            .unwrap_or_else(|| "-".to_string())
    )));
    lines.push(Line::from(""));
    lines.push(Line::from("Keys"));
    lines.push(Line::from("F2: models"));
    lines.push(Line::from("F3: workspaces"));
    lines.push(Line::from("F1: help"));
    lines.push(Line::from("Esc: quit"));
    lines.push(Line::from("Ctrl+R: reload"));

    Paragraph::new(Text::from(lines))
        .block(Block::default().borders(Borders::ALL).title("Info"))
        .wrap(Wrap { trim: false })
}

fn render_input(app: &App) -> Paragraph<'static> {
    let input = app.input.iter().collect::<String>();

    let text = if app.cute == CuteMode::Off {
        Text::from(input)
    } else {
        let prefix = input_prompt_prefix(app.cute);
        Text::from(Line::from(vec![
            Span::styled(prefix, Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
            Span::raw(input),
        ]))
    };

    Paragraph::new(text)
        .block({
            let mut block = Block::default()
                .borders(Borders::ALL)
                .border_type(BorderType::Rounded);
            if app.cute != CuteMode::Off {
                let c = if app.waiting { c_heart() } else { c_muted() };
                block = block.border_style(Style::default().fg(c));
            }
            block
        })
        .wrap(Wrap { trim: false })
}

fn render_model_picker_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title("Models (Up/Down, Enter select, Esc cancel)");
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1)])
        .split(inner);

    let list = render_model_picker(app);
    f.render_stateful_widget(list, rows[0], &mut app.model_state);

    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[1]);
}

fn render_workspace_picker_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title("Workspaces (Up/Down, Enter select, Esc cancel, Ctrl+R refresh)");
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1)])
        .split(inner);

    let list = render_workspace_picker(app);
    f.render_stateful_widget(list, rows[0], &mut app.workspace_state);

    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[1]);
}

fn render_choice_modal_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let title = app
        .choice_prompt
        .as_ref()
        .map(|p| p.title.as_str())
        .unwrap_or("Choose");
    let hint = app
        .choice_prompt
        .as_ref()
        .map(|p| p.hint.as_str())
        .unwrap_or("Tab/↑↓ select • Enter choose • Esc cancel");

    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title(title);
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1), Constraint::Length(1)])
        .split(inner);

    let list = render_choice_list(app);
    f.render_stateful_widget(list, rows[0], &mut app.choice_state);

    let hint_line = Paragraph::new(Line::from(vec![Span::styled(
        hint.to_string(),
        Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
    )]))
    .alignment(Alignment::Center);
    f.render_widget(hint_line, rows[1]);

    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[2]);
}

fn render_text_prompt_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let prompt = app
        .text_prompt
        .as_ref()
        .map(|p| p.prompt.as_str())
        .unwrap_or("Input");
    let input = app
        .text_prompt
        .as_ref()
        .map(|p| p.input.iter().collect::<String>())
        .unwrap_or_default();
    let cursor = app.text_prompt.as_ref().map(|p| p.cursor).unwrap_or(0);

    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title("Input (Enter submit, Esc cancel)");
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let prefix = "> ";
    let lines = vec![
        Line::from(vec![Span::styled(
            prompt.to_string(),
            Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
        )]),
        Line::from(""),
        Line::from(vec![
            Span::styled(prefix, Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)),
            Span::raw(input.clone()),
        ]),
    ];

    let widget = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
    f.render_widget(widget, inner);

    // Cursor on input line.
    let x = inner
        .x
        .saturating_add(prefix.width() as u16)
        .saturating_add(cursor as u16);
    let y = inner.y.saturating_add(2);
    if x < inner.x + inner.width && y < inner.y + inner.height {
        f.set_cursor_position((x, y));
    }
}

fn render_stargazing_bar(app: &App) -> Paragraph<'static> {
    if app.cute == CuteMode::Off {
        return Paragraph::new(Line::from(""));
    }

    // From SPECS/LOADINGSPECS.md.
    const SYMBOLS: &[&str] = &["✦", "✧", "✶", "✷", "✶", "✧"];
    let sym = SYMBOLS[(app.spinner_step as usize) % SYMBOLS.len()];
    let sym_style = match sym {
        "✶" | "✷" => Style::default().fg(c_heart()).add_modifier(Modifier::BOLD),
        _ => Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD),
    };

    let line = Line::from(vec![
        Span::styled(
            "stargazing… ",
            Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
        ),
        Span::styled(sym.to_string(), sym_style),
    ]);

    Paragraph::new(line).alignment(Alignment::Center)
}

fn render_model_picker(app: &App) -> List<'static> {
    let items = if app.model_options.is_empty() {
        vec![ListItem::new("No models loaded.")]
    } else {
        app.model_options
            .iter()
            .map(|m| {
                let id = format!(
                    "{}{}",
                    m.provider,
                    m.model
                        .as_deref()
                        .map(|v| format!(":{v}"))
                        .unwrap_or_default()
                );
                ListItem::new(Line::from(format!("{:<24} {}", id, m.label)))
            })
            .collect::<Vec<_>>()
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn render_workspace_picker(app: &App) -> List<'static> {
    let items = if app.workspace_options.is_empty() {
        vec![ListItem::new("No workspaces loaded.")]
    } else {
        app.workspace_options
            .iter()
            .map(|w| {
                let archived = if w.archived { " (archived)" } else { "" };
                let mut line = format!("{}{}  ({})", w.name, archived, w.id);
                if let Some(ref root) = w.root_path {
                    let root_disp = tail_truncate_to_width(root, 46);
                    line.push_str(&format!("  {root_disp}"));
                }
                if let Some(ref last) = w.last_used_at {
                    let last_disp = tail_truncate_to_width(last, 26);
                    line.push_str(&format!("  last={last_disp}"));
                }
                ListItem::new(Line::from(line))
            })
            .collect::<Vec<_>>()
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn render_thread_picker_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title("Threads (Up/Down, Enter select, Esc cancel, Ctrl+N new)");
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1)])
        .split(inner);

    let list = render_thread_picker(app);
    f.render_stateful_widget(list, rows[0], &mut app.thread_state);

    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[1]);
}

fn render_thread_picker(app: &App) -> List<'static> {
    let items = if app.thread_options.is_empty() {
        vec![ListItem::new("No threads loaded.")]
    } else {
        app.thread_options
            .iter()
            .map(|t| {
                let pin_indicator = if t.is_pinned { "📌 " } else { "" };
                let mode_str = t.mode.as_deref().unwrap_or("chat");
                let mut line = format!("{}{} [{}] ({} msgs)", pin_indicator, t.title, mode_str, t.message_count);
                if let Some(ref last) = t.last_message_at {
                    let last_disp = tail_truncate_to_width(last, 26);
                    line.push_str(&format!(" • {}", last_disp));
                }
                ListItem::new(Line::from(line))
            })
            .collect::<Vec<_>>()
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn render_memory_panel_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let status = if app.memory_enabled { "ON" } else { "OFF" };
    let title = format!(
        "Memory {} • {} items (Ctrl+M toggle, Del delete, Esc close)",
        status,
        app.memory_items.len()
    );

    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title(title);
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(2), Constraint::Length(1)])
        .split(inner);

    // Memory list
    let list = render_memory_list(app);
    f.render_stateful_widget(list, rows[0], &mut app.memory_state);

    // Settings info
    let settings_text = format!(
        "Settings: Max {} items, {} tokens context • Scope: {}{}",
        app.memory_settings.max_items_injected,
        app.memory_settings.max_context_tokens,
        if app.memory_settings.include_global { "global" } else { "" },
        if app.memory_settings.include_project { "+project" } else { "" }
    );
    let settings_para = Paragraph::new(Line::from(vec![Span::styled(
        settings_text,
        Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
    )]))
    .alignment(Alignment::Center);
    f.render_widget(settings_para, rows[1]);

    // Stargazing bar
    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[2]);
}

fn render_memory_list(app: &App) -> List<'static> {
    let items = if app.memory_items.is_empty() {
        vec![ListItem::new("No memory items yet. Memory will be captured automatically.")]
    } else {
        app.memory_items
            .iter()
            .map(|m| {
                let enabled_indicator = if m.enabled { "✓" } else { "✗" };
                let type_badge = match m.item_type.as_str() {
                    "fact" => "📝",
                    "preference" => "⭐",
                    "decision" => "🎯",
                    "constraint" => "🔒",
                    "todo" => "☑",
                    _ => "•",
                };

                // Truncate content for display
                let content_disp = if m.content.len() > 80 {
                    format!("{}...", &m.content[..77])
                } else {
                    m.content.clone()
                };

                let line = format!(
                    "{} {} [{}] {} (salience: {:.2}, {})",
                    enabled_indicator,
                    type_badge,
                    m.scope,
                    content_disp,
                    m.salience,
                    m.source
                );

                ListItem::new(Line::from(line))
            })
            .collect::<Vec<_>>()
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn render_choice_list(app: &App) -> List<'static> {
    let items = if let Some(ref prompt) = app.choice_prompt {
        if prompt.options.is_empty() {
            vec![ListItem::new("No options.")]
        } else {
            prompt
                .options
                .iter()
                .map(|o| {
                    let mut lines = Vec::new();
                    lines.push(Line::from(vec![Span::styled(
                        o.label.clone(),
                        Style::default().add_modifier(Modifier::BOLD),
                    )]));
                    if !o.description.trim().is_empty() {
                        lines.push(Line::from(vec![Span::styled(
                            o.description.clone(),
                            Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
                        )]));
                    }
                    ListItem::new(Text::from(lines))
                })
                .collect::<Vec<_>>()
        }
    } else {
        vec![ListItem::new("No active prompt.")]
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn border_blink_spans(step: u64, area_width: u16) -> Vec<Span<'static>> {
    // A small star cluster that travels right and "repairs" (re-draws) the border behind it.
    // No bullets/dots: empty space is the actual border line char.
    let usable = area_width.saturating_sub(2) as usize; // exclude corners
    if usable < 6 {
        return Vec::new();
    }

    const HEAD_W: usize = 5;
    let max_offset = usable.saturating_sub(HEAD_W);

    let grow_len = 5usize;
    let shrink_len = 4usize;
    // After reaching 5 stars, move the cluster right for ~15 steps (clamped to fit).
    let slide_len = 15usize.min(max_offset.saturating_sub(grow_len + shrink_len - 1));
    let total = grow_len + slide_len + shrink_len;
    if total == 0 {
        return Vec::new();
    }

    let idx = (step as usize) % total;

    let (offset, stars, right_align) = if idx < grow_len {
        // "Grow" diagonally: 1..5 stars.
        (idx, idx + 1, false)
    } else if idx < grow_len + slide_len {
        // Slide right with 5 stars.
        (idx, 5, false)
    } else {
        // Shrink: 4..1 stars, right-aligned inside the 5-cell head.
        let sidx = idx - (grow_len + slide_len);
        (idx, 4usize.saturating_sub(sidx), true)
    };

    let offset = offset.min(max_offset);

    let border_style = Style::default().fg(c_sparkle());
    let rainbow = |slot: usize| -> Color {
        match slot {
            0 => Color::Rgb(239, 68, 68),   // red
            1 => c_star(),                  // yellow
            2 => Color::Rgb(34, 197, 94),   // green
            3 => Color::Rgb(59, 130, 246),  // blue
            _ => c_brand(),                 // purple
        }
    };

    let mut spans: Vec<Span<'static>> = Vec::new();

    // Re-draw the border behind the moving head.
    if offset > 0 {
        spans.push(Span::styled("─".repeat(offset), border_style));
    }

    for i in 0..HEAD_W {
        let in_star = if stars == 0 {
            false
        } else if right_align {
            i >= HEAD_W.saturating_sub(stars)
        } else {
            i < stars
        };
        if in_star {
            spans.push(Span::styled(
                "★",
                Style::default()
                    .fg(rainbow(i))
                    .add_modifier(Modifier::BOLD),
            ));
        } else {
            spans.push(Span::styled("─", border_style));
        }
    }

    spans
}

fn render_help(_app: &App) -> Paragraph<'static> {
    let lines = vec![
        Line::from(vec![Span::styled(
            "Starbot TUI",
            Style::default().add_modifier(Modifier::BOLD),
        )]),
        Line::from(""),
        Line::from("Enter: send prompt"),
        Line::from("Esc: quit (or close popup)"),
        Line::from("F2 / Ctrl+M: model picker"),
        Line::from("F3: workspace picker"),
        Line::from("Ctrl+R: reload"),
        Line::from("PgUp/PgDn: scroll chat"),
        Line::from("Ctrl+D: toggle debug panel"),
        Line::from(""),
        Line::from("Choice modal: Tab/↑↓ select, Enter choose, Esc cancel"),
        Line::from("Input modal: type, Enter submit, Esc cancel"),
        Line::from(""),
        Line::from(
            "If chat says missing token: run `starbott auth login` or use scripts/starbott-dev.sh.",
        ),
    ];

    Paragraph::new(Text::from(lines))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title("Help (Esc to close)"),
        )
        .wrap(Wrap { trim: false })
}

fn render_tool_card(app: &App) -> Paragraph<'static> {
    let mut lines = Vec::new();

    if let Some(ref tool) = app.pending_tool {
        lines.push(Line::from(vec![Span::styled(
            format!("Tool: {}", tool.tool_name),
            Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD),
        )]));
        lines.push(Line::from(""));

        if !tool.target_files.is_empty() {
            lines.push(Line::from(vec![Span::styled(
                "Files:",
                Style::default().add_modifier(Modifier::BOLD),
            )]));
            for file in &tool.target_files {
                lines.push(Line::from(format!("  {file}")));
            }
            lines.push(Line::from(""));
        }

        if !tool.preview.is_empty() {
            lines.push(Line::from(vec![Span::styled(
                "Preview:",
                Style::default().add_modifier(Modifier::BOLD),
            )]));
            for preview_line in tool.preview.lines().take(15) {
                let style = if preview_line.starts_with('+') {
                    Style::default().fg(c_ok())
                } else if preview_line.starts_with('-') {
                    Style::default().fg(c_heart())
                } else {
                    Style::default().fg(c_muted())
                };
                lines.push(Line::from(vec![Span::styled(
                    format!("  {preview_line}"),
                    style,
                )]));
            }
            lines.push(Line::from(""));
        }

        if tool.requires_confirmation {
            lines.push(Line::from(vec![
                Span::styled("Y", Style::default().fg(c_ok()).add_modifier(Modifier::BOLD)),
                Span::raw(" Approve  "),
                Span::styled("N", Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
                Span::raw(" Deny"),
            ]));
        }
    } else {
        lines.push(Line::from("No pending tool."));
    }

    Paragraph::new(Text::from(lines))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title("Tool Approval (Y/N)"),
        )
        .wrap(Wrap { trim: false })
}

fn seed_rng() -> u64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    now.as_nanos() as u64 ^ 0x9E37_79B9_7F4A_7C15u64
}

fn rand_idx(rng: &mut u64, len: usize) -> usize {
    if len == 0 {
        return 0;
    }
    *rng = rng
        .wrapping_mul(6364136223846793005)
        .wrapping_add(1442695040888963407);
    ((*rng >> 32) as usize) % len
}

fn pick_phrase(
    rng: &mut u64,
    last: &mut Option<&'static str>,
    pool: &'static [&'static str],
) -> &'static str {
    if pool.is_empty() {
        return "";
    }
    let mut idx = rand_idx(rng, pool.len());
    if let Some(prev) = last {
        if pool.len() > 1 && pool[idx] == *prev {
            idx = (idx + 1) % pool.len();
        }
    }
    let chosen = pool[idx];
    *last = Some(chosen);
    chosen
}

fn startup_status(cute: CuteMode) -> &'static str {
    match cute {
        CuteMode::On => "hi bestie ✨",
        CuteMode::Minimal => "Loading…",
        CuteMode::Off => "Loading models...",
    }
}

pub fn ready_status(cute: CuteMode, previous: &str) -> String {
    // Don't overwrite a more specific status (like an error).
    if previous.to_ascii_lowercase().contains("failed")
        || previous.to_ascii_lowercase().contains("error")
    {
        return previous.to_string();
    }
    match cute {
        CuteMode::On => "we're in, babe.".to_string(),
        CuteMode::Minimal => "Ready".to_string(),
        CuteMode::Off => "Ready.".to_string(),
    }
}

pub fn thinking_status(cute: CuteMode, rng: &mut u64, last: &mut Option<&'static str>) -> String {
    match cute {
        CuteMode::On => {
            const POOL: &[&str] = &[
                "stargazing... ✦",
                "consulting the constellations...",
                "picking the prettiest brain for this...",
            ];
            pick_phrase(rng, last, POOL).to_string()
        }
        CuteMode::Minimal => "Thinking...".to_string(),
        CuteMode::Off => "Sending...".to_string(),
    }
}

pub fn format_success_status(
    cute: CuteMode,
    rng: &mut u64,
    last: &mut Option<&'static str>,
    success_count: u32,
    lane: Option<Lane>,
) -> String {
    match cute {
        CuteMode::Off => "ok".to_string(),
        CuteMode::Minimal => "♡✓".to_string(),
        CuteMode::On => {
            let big_win = success_count == 1 || matches!(lane, Some(Lane::Deep));
            if big_win {
                // Keep this low frequency: first win, or deep lane.
                const POOL: &[&str] = &[
                    "verified. ♡ c'est bon.",
                    "sealed with a heart.",
                    "green flags only. ✓",
                ];
                let phrase = pick_phrase(rng, last, POOL);
                format!("♡✓ {phrase}")
            } else {
                "♡✓".to_string()
            }
        }
    }
}

pub fn format_error_status(
    cute: CuteMode,
    rng: &mut u64,
    last: &mut Option<&'static str>,
    err: &CliError,
) -> String {
    let (is_auth, is_scary) = match err {
        CliError::Auth(_) => (true, true),
        CliError::RateLimited(_) => (false, true),
        CliError::Server(_) => (false, true),
        _ => (false, false),
    };

    if is_auth {
        return "Authentication required. (No cute mode for auth errors.)".to_string();
    }

    let message = err.to_string();
    match cute {
        CuteMode::Off => format!("error: {message}"),
        CuteMode::Minimal => format!("♡! {message}"),
        CuteMode::On => {
            if is_scary {
                return format!("♡! {message}");
            }
            const POOL: &[&str] = &["oops - something's off.", "tiny detour, same destination."];
            let phrase = pick_phrase(rng, last, POOL);
            format!("♡! {phrase} {message}")
        }
    }
}

fn c_star() -> Color {
    Color::Rgb(255, 205, 86)
}

fn c_heart() -> Color {
    // Hot pink (closer to "actual pink" than red).
    Color::Rgb(255, 45, 149)
}

fn c_sparkle() -> Color {
    // Cyan.
    Color::Rgb(0, 255, 255)
}

fn c_brand() -> Color {
    Color::Rgb(131, 56, 236)
}

fn c_ok() -> Color {
    Color::Rgb(22, 163, 74)
}

fn c_warn() -> Color {
    Color::Rgb(245, 158, 11)
}

fn c_muted() -> Color {
    Color::Rgb(100, 116, 139)
}

// Base UI animation cadence. Keep stable; derive faster per-element animations
// from elapsed time rather than changing this (which would affect borders, etc.).
const SPINNER_INTERVAL_MS: u64 = 275;

fn update_spinner(app: &mut App) {
    if app.cute == CuteMode::Off {
        return;
    }
    if !app.waiting
        && app.bg_tasks == 0
        && !matches!(
            app.mode,
            Mode::ModelPicker | Mode::WorkspacePicker | Mode::ChoiceModal | Mode::TextPromptModal
        )
    {
        return;
    }
    // Keep this independent of the render loop cadence.
    // Speed: ~2x faster than the previous 550ms tick.
    let now = Instant::now();
    if now.duration_since(app.spinner_last) >= Duration::from_millis(SPINNER_INTERVAL_MS) {
        app.spinner_last = now;
        app.spinner_step = app.spinner_step.wrapping_add(1);
    }
}

fn header_left_line(app: &App) -> Line<'static> {
    match app.cute {
        CuteMode::Off => {
            let lane = lane_plain(app.lane);
            Line::from(vec![
                Span::raw(" Starbot  "),
                Span::styled(lane, Style::default().add_modifier(Modifier::DIM)),
                Span::raw(" "),
            ])
        }
        CuteMode::Minimal | CuteMode::On => {
            let mut spans: Vec<Span<'static>> = Vec::new();
            spans.push(Span::raw(" "));
            spans.extend(bespoke_star_spans());
            spans.push(Span::raw(" "));
            spans.push(Span::styled(
                "Starbot",
                Style::default()
                    .fg(c_brand())
                    .add_modifier(Modifier::BOLD),
            ));
            spans.push(Span::raw(" "));
            spans.push(Span::styled(
                "♡",
                Style::default().fg(c_heart()).add_modifier(Modifier::BOLD),
            ));
            spans.push(Span::raw("  "));
            spans.extend(lane_spans(app.lane));
            spans.push(Span::raw(" "));
            Line::from(spans)
        }
    }
}

fn bespoke_star_spans() -> Vec<Span<'static>> {
    vec![
        Span::styled("˖", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
        Span::styled("✦", Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)),
        Span::styled("★", Style::default().fg(c_star()).add_modifier(Modifier::BOLD)),
        Span::styled("✦", Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)),
        Span::styled("˖", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
    ]
}

fn lane_plain(lane: Option<Lane>) -> String {
    match lane.unwrap_or(Lane::Standard) {
        Lane::Quick => "lane=quick".to_string(),
        Lane::Standard => "lane=standard".to_string(),
        Lane::Deep => "lane=deep".to_string(),
    }
}

fn lane_spans(lane: Option<Lane>) -> Vec<Span<'static>> {
    match lane.unwrap_or(Lane::Standard) {
        Lane::Quick => vec![Span::styled(
            "★",
            Style::default().fg(c_star()).add_modifier(Modifier::BOLD),
        )],
        Lane::Standard => vec![
            Span::styled("★", Style::default().fg(c_star()).add_modifier(Modifier::BOLD)),
            Span::styled("♡", Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
        ],
        Lane::Deep => vec![
            Span::styled("★", Style::default().fg(c_star()).add_modifier(Modifier::BOLD)),
            // "Wings" for deep lane (SPEC5) with a little sparkle tint.
            Span::styled("︵", Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)),
            Span::styled("♡", Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
        ],
    }
}

fn provider_health_spans(cute: CuteMode, hints: ProviderHints) -> Vec<Span<'static>> {
    // Keep serious, short, and stable.
    let v = match hints.vertex_ok {
        Some(true) => ("OK", Some(true)),
        Some(false) => ("?", Some(false)),
        None => ("?", None),
    };
    let a = if hints.azure_present { ("OK", Some(true)) } else { ("?", None) };
    let cf = if hints.cf_present { ("OK", Some(true)) } else { ("?", None) };

    if cute == CuteMode::Off {
        return vec![Span::raw(format!(
            "Vertex:{} Azure:{} CF:{}",
            v.0, a.0, cf.0
        ))];
    }

    let status_style = |ok: Option<bool>| match ok {
        Some(true) => Style::default().fg(c_ok()).add_modifier(Modifier::BOLD),
        Some(false) => Style::default().fg(c_warn()).add_modifier(Modifier::BOLD),
        None => Style::default().fg(c_warn()).add_modifier(Modifier::BOLD),
    };

    vec![
        Span::styled("Vertex: ", Style::default().fg(c_muted())),
        Span::styled(v.0, status_style(v.1)),
        Span::raw("  "),
        Span::styled("Azure: ", Style::default().fg(c_muted())),
        Span::styled(a.0, status_style(a.1)),
        Span::raw("  "),
        Span::styled("CF: ", Style::default().fg(c_muted())),
        Span::styled(cf.0, status_style(cf.1)),
    ]
}

fn ping_pong_index(step: u64, len: usize) -> usize {
    if len <= 1 {
        return 0;
    }
    let period = (len - 1) * 2;
    let pos = (step as usize) % period;
    if pos < len {
        pos
    } else {
        period - pos
    }
}

fn spinner_spans(cute: CuteMode, lane: Option<Lane>, step: u64) -> Option<Vec<Span<'static>>> {
    if cute == CuteMode::Off {
        return None;
    }

    // From SPECS/LOADINGSPECS.md (multi-char, ping-pong loop).
    // Keep frame width stable so the status line doesn't jitter.
    const MAIN: &[&str] = &["✦ ★", "✦★ ", " ★✦", "★ ✦"];

    let _ = lane;
    let idx = ping_pong_index(step, MAIN.len());
    let frame = MAIN[idx];

    let modif = match cute {
        CuteMode::On => Modifier::BOLD,
        CuteMode::Minimal => Modifier::BOLD,
        CuteMode::Off => Modifier::empty(),
    };

    let mut spans: Vec<Span<'static>> = Vec::new();
    for ch in frame.chars() {
        let style = match ch {
            '★' => Style::default().fg(c_heart()).add_modifier(modif),
            '✦' | '✧' => Style::default().fg(c_sparkle()).add_modifier(modif),
            _ => Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
        };
        if ch == ' ' {
            spans.push(Span::raw(" "));
        } else {
            spans.push(Span::styled(ch.to_string(), style));
        }
    }

    Some(spans)
}

fn input_title_line(app: &App) -> Line<'static> {
    match app.cute {
        CuteMode::Off => {
            let lane = lane_plain(app.lane);
            if app.waiting {
                return Line::from(format!("Input (waiting)  {lane}  {}", app.status));
            }
            Line::from(format!("Input  {lane}  {}", app.status))
        }
        CuteMode::Minimal | CuteMode::On => {
            let mut spans: Vec<Span<'static>> = Vec::new();
            spans.push(Span::styled(
                "Input",
                Style::default().add_modifier(Modifier::BOLD),
            ));
            spans.push(Span::raw("  "));

            if app.waiting {
                if let Some(sp) = spinner_spans(app.cute, app.lane, app.spinner_step) {
                    spans.extend(sp);
                    spans.push(Span::raw("  "));
                }
            }

            spans.extend(lane_spans(app.lane));
            spans.push(Span::raw("  "));
            spans.push(Span::styled(app.status.clone(), Style::default().fg(c_muted())));
            Line::from(spans)
        }
    }
}

pub fn parse_lane(payload: &Value) -> Option<Lane> {
    payload
        .get("triage")
        .and_then(|v| v.get("lane"))
        .and_then(|v| v.as_str())
        .and_then(Lane::from_str)
}

pub fn parse_vertex_ok(payload: &Value) -> Option<bool> {
    let providers = payload.get("providers")?;
    let gemini = providers.get("gemini")?.as_str()?;
    match gemini {
        "available" => Some(true),
        "unavailable" => Some(false),
        _ => None,
    }
}

fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);

    let vertical = popup_layout[1];
    let popup_layout = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(vertical);

    popup_layout[1]
}

fn build_chat_lines(
    messages: &[ChatMsg],
    width: usize,
    cute: CuteMode,
    spinner_step: u64,
    spinner_last: Instant,
) -> Vec<Line<'static>> {
    let mut out = Vec::new();

    // Startup splash: STARBOT wordmark + mascot.
    out.extend(startup_banner_lines(width, cute));

    for msg in messages {
        let typing = msg.role == ChatRole::Assistant && !msg.sendable;
        let (prefix_spans, content_style) = if cute == CuteMode::Off {
            let (tag, tag_style, content_style) = match msg.role {
                ChatRole::User => (
                    "You",
                    Style::default().fg(c_ok()).add_modifier(Modifier::BOLD),
                    Style::default(),
                ),
                ChatRole::Assistant => (
                    "AI",
                    Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD),
                    Style::default(),
                ),
                ChatRole::System => (
                    "SYS",
                    Style::default().fg(c_warn()).add_modifier(Modifier::BOLD),
                    Style::default().add_modifier(Modifier::DIM),
                ),
            };

            let prefix = format!("[{tag}] ");
            (vec![Span::styled(prefix, tag_style)], content_style)
        } else {
            match msg.role {
                ChatRole::User => (
                    vec![Span::styled(
                        "♡ ",
                        Style::default().fg(c_heart()).add_modifier(Modifier::BOLD),
                    )],
                    Style::default(),
                ),
                ChatRole::Assistant => {
                    (
                        assistant_prefix_spans(cute, spinner_step, typing),
                        if typing {
                            assistant_typing_style()
                        } else {
                            Style::default()
                        },
                    )
                }
                ChatRole::System => (
                    vec![Span::styled(
                        "💬 ",
                        Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
                    )],
                    Style::default().add_modifier(Modifier::DIM),
                ),
            }
        };

        let prefix_len = spans_width(&prefix_spans);
        let content_raw = if typing {
            assistant_typing_content(spinner_step, spinner_last)
        } else {
            msg.content.as_str()
        };
        let content = content_raw.replace("\r\n", "\n");
        let indent = " ".repeat(prefix_len);
        let avail = width.saturating_sub(prefix_len).max(1);

        for (idx, line) in content.split('\n').enumerate() {
            let remaining = line.trim_end_matches('\r');
            if idx == 0 {
                if remaining.is_empty() {
                    out.push(Line::from(prefix_spans.clone()));
                    continue;
                }
                let wrapped = wrap_line(remaining, avail);
                for (widx, part) in wrapped.into_iter().enumerate() {
                    if widx == 0 {
                        let mut spans = Vec::new();
                        spans.extend(prefix_spans.clone());
                        spans.push(Span::styled(part, content_style));
                        out.push(Line::from(spans));
                    } else {
                        out.push(Line::from(vec![
                            Span::raw(indent.clone()),
                            Span::styled(part, content_style),
                        ]));
                    }
                }
            } else {
                if remaining.is_empty() {
                    out.push(Line::from(""));
                    continue;
                }
                let wrapped = wrap_line(remaining, avail);
                for part in wrapped {
                    out.push(Line::from(vec![
                        Span::raw(indent.clone()),
                        Span::styled(part, content_style),
                    ]));
                }
            }
        }

        // spacer line between messages
        out.push(Line::from(""));
    }
    out
}

fn startup_banner_lines(width: usize, cute: CuteMode) -> Vec<Line<'static>> {
    // From SPECS/SPEC12.md (mascot art). Keep as-is (Unicode); terminals/fonts that
    // support braille/block symbols will render it nicely.
    const MASCOT: &[&str] = &[
        "⠀⠀⠀⢀⣠⣤⣤⣤⣀⠀⠀⠀⠀⣀⣠⣤⣤⣤⣄⡀⠀⠀⠀⠀⠀",
        "⠀⠀⣠⣿⠿⠛⠛⠛⠛⠛⢿⣷⣤⣾⠿⠛⠛⠙⠛⠛⠿⠗⠀⠀⠀⠀",
        "⠀⣾⡿⠁⠀⠀⠀⠀⠀⠀⠀⠙⡿⠁⠀⢀⣤⣀⠀⠀⢀⣤⣶⡆⠀⠀",
        "⢸⣿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀",
        "⠸⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣧⣄⠀",
        "⠀⢹⣿⠀⣿⣷⣄⣀⣤⡄⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⠷",
        "⠀⠀⣁⣤⣿⣿⣿⣿⣿⠃⠀⠀⠀⠀⠘⠛⠛⠛⠻⣿⣿⣿⠋⠉⠀⠀",
        "⠀⠘⠻⢿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⢀⡀⠹⣿⡟⠀⠀⠀⠀",
        "⠀⠀⠀⠀⢹⣿⠟⢙⠛⠛⠀⠀⠀⠀⠀⣀⣴⡿⠓⠀⠀⠀⠀⠀⠀⠀",
        "⠀⠀⠀⠀⠈⠁⠀⠈⠻⢿⣦⣄⠀⣠⣾⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀",
        "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢿⣿⠿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
        "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
    ];

    // Wordmark (requested). Keep as-is to preserve the look.
    const LOGO: &[&str] = &[
        "★   *      .       .   *   .   *",
        "   .     *      .    ★     .      .",
        "        ███████╗████████╗ █████╗ ██████╗ ██████╗  ██████╗ ████████╗",
        "        ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██╔══██╗██╔═══██╗╚══██╔══╝",
        "        ███████╗   ██║   ███████║██████╔╝██████╔╝██║   ██║   ██║",
        "        ╚════██║   ██║   ██╔══██║██╔══██╗██╔══██╗██║   ██║   ██║",
        "  ★     ███████║   ██║   ██║  ██║██║  ██║██████╔╝╚██████╔╝   ██║     ★",
        "        ╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝╚═════╝  ╚═════╝    ╚═╝",
        "    .    *    .   *      .   *   .   *",
    ];

    let mut out = Vec::new();

    let logo_w = LOGO
        .iter()
        .map(|l| l.trim_end().width())
        .max()
        .unwrap_or(0);
    let mascot_w = MASCOT.iter().map(|l| l.width()).max().unwrap_or(0);
    let min_gap = if logo_w == 0 || mascot_w == 0 { 0 } else { 3 };
    let combined_w = logo_w + min_gap + mascot_w;

    let logo_style_for_row = |row: usize| -> Style {
        if cute == CuteMode::Off {
            return Style::default();
        }
        // Starfield lines + the "ambient" row.
        if row <= 1 || row + 1 == LOGO.len() {
            return Style::default().fg(c_muted()).add_modifier(Modifier::DIM);
        }
        // Big block wordmark.
        Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)
    };

    let mascot_style = if cute == CuteMode::Off {
        Style::default()
    } else {
        Style::default().fg(c_muted()).add_modifier(Modifier::DIM)
    };

    // Prefer side-by-side (logo left, mascot right). If the terminal is too narrow,
    // fall back to stacked so the art doesn't wrap.
    if width < combined_w {
        // ── Stacked ──
        let logo_pad = width.saturating_sub(logo_w) / 2;
        for (row, line) in LOGO.iter().enumerate() {
            out.push(Line::from(vec![
                Span::raw(" ".repeat(logo_pad)),
                Span::styled((*line).to_string(), logo_style_for_row(row)),
            ]));
        }
        out.push(Line::from(""));
        let mascot_pad = width.saturating_sub(mascot_w) / 2;
        for line in MASCOT {
            out.push(Line::from(vec![
                Span::raw(" ".repeat(mascot_pad)),
                Span::styled((*line).to_string(), mascot_style),
            ]));
        }
        out.push(Line::from(""));
        return out;
    }

    // ── Side-by-side ──
    let total_h = LOGO.len().max(MASCOT.len());

    for row in 0..total_h {
        let logo_line = LOGO.get(row).copied().unwrap_or("").trim_end();
        let logo_w_row = logo_line.width();
        let mascot_line = MASCOT.get(row).copied().unwrap_or("");

        // Logo is left-aligned; mascot is right-aligned.
        // Insert spaces so the mascot's max-width block ends at the right edge.
        let space_between = width
            .saturating_sub(mascot_w)
            .saturating_sub(logo_w_row);

        out.push(Line::from(vec![
            Span::styled(logo_line.to_string(), logo_style_for_row(row)),
            Span::raw(" ".repeat(space_between)),
            Span::styled(mascot_line.to_string(), mascot_style),
        ]));
    }

    out.push(Line::from(""));
    out
}

fn wrap_line(line: &str, width: usize) -> Vec<String> {
    if width <= 1 {
        return vec![line.to_string()];
    }
    let mut out = Vec::new();
    let mut cur = String::new();

    for word in line.split_whitespace() {
        if cur.is_empty() {
            cur.push_str(word);
            continue;
        }
        if cur.as_str().width() + 1 + word.width() <= width {
            cur.push(' ');
            cur.push_str(word);
        } else {
            out.push(cur);
            cur = word.to_string();
        }
    }

    if !cur.is_empty() {
        out.push(cur);
    }

    if out.is_empty() {
        out.push(String::new());
    }

    out
}

fn truncate_to_width(input: &str, max_width: usize) -> String {
    if max_width == 0 {
        return String::new();
    }
    if input.width() <= max_width {
        return input.to_string();
    }

    const ELLIPSIS: &str = "…";
    let ell_w = ELLIPSIS.width();
    if max_width <= ell_w {
        return ELLIPSIS.to_string();
    }

    let mut out = String::new();
    let mut w = 0usize;
    for ch in input.chars() {
        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
        if w + cw + ell_w > max_width {
            break;
        }
        out.push(ch);
        w += cw;
    }
    out.push_str(ELLIPSIS);
    out
}

fn tail_truncate_to_width(input: &str, max_width: usize) -> String {
    if max_width == 0 {
        return String::new();
    }
    if input.width() <= max_width {
        return input.to_string();
    }

    const ELLIPSIS: &str = "…";
    if max_width == 1 {
        return ELLIPSIS.to_string();
    }
    if max_width == 2 {
        return "..".to_string();
    }

    let keep_w = max_width.saturating_sub(2);
    let mut tail_rev = String::new();
    let mut w = 0usize;
    for ch in input.chars().rev() {
        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
        if w + cw > keep_w {
            break;
        }
        tail_rev.push(ch);
        w += cw;
    }
    let tail: String = tail_rev.chars().rev().collect();
    format!("..{tail}")
}

fn input_prompt_len(app: &App) -> usize {
    input_prompt_prefix(app.cute).width()
}

fn spans_width(spans: &[Span<'_>]) -> usize {
    spans.iter().map(|s| s.content.as_ref().width()).sum()
}

fn truncate_spans_to_width(spans: &[Span<'static>], max_width: usize) -> Vec<Span<'static>> {
    if max_width == 0 {
        return Vec::new();
    }
    if spans_width(spans) <= max_width {
        return spans.to_vec();
    }

    const ELLIPSIS: &str = "…";
    let ell_w = ELLIPSIS.width();
    if max_width <= ell_w {
        return vec![Span::raw(ELLIPSIS)];
    }

    let mut out: Vec<Span<'static>> = Vec::new();
    let mut used = 0usize;
    let limit = max_width.saturating_sub(ell_w);

    for sp in spans {
        let style = sp.style;
        let text = sp.content.as_ref();
        if text.is_empty() {
            continue;
        }

        let mut chunk = String::new();
        for ch in text.chars() {
            let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
            if used + cw > limit {
                break;
            }
            chunk.push(ch);
            used += cw;
        }

        if !chunk.is_empty() {
            out.push(Span::styled(chunk, style));
        }

        if used >= limit {
            break;
        }
    }

    out.push(Span::raw(ELLIPSIS));
    out
}

fn compose_lr_line(
    total_width: usize,
    left: &[Span<'static>],
    right: &[Span<'static>],
) -> Line<'static> {
    if total_width == 0 {
        return Line::from("");
    }

    let right_w = spans_width(right);
    if right_w == 0 {
        return Line::from(truncate_spans_to_width(left, total_width));
    }
    if right_w >= total_width {
        return Line::from(truncate_spans_to_width(right, total_width));
    }

    let max_left = total_width.saturating_sub(right_w).saturating_sub(1);
    let left_trunc = truncate_spans_to_width(left, max_left);
    let left_w = spans_width(&left_trunc);
    let spaces = total_width.saturating_sub(left_w + right_w);

    let mut spans: Vec<Span<'static>> = Vec::new();
    spans.extend(left_trunc);
    if spaces > 0 {
        spans.push(Span::raw(" ".repeat(spaces)));
    }
    spans.extend(right.iter().cloned());
    Line::from(spans)
}

fn input_prompt_prefix(cute: CuteMode) -> &'static str {
    if cute == CuteMode::Off {
        ""
    } else {
        "♡ "
    }
}

fn assistant_prefix_spans(cute: CuteMode, step: u64, typing: bool) -> Vec<Span<'static>> {
    let _ = (cute, step, typing);
    // Keep prefix width stable but remove the old star+sparkle glyphs.
    let star = Span::styled("⭐", Style::default().fg(c_star()).add_modifier(Modifier::BOLD));
    vec![Span::raw(" "), star, Span::raw(" ")]
}

fn assistant_typing_content(spinner_step: u64, spinner_last: Instant) -> &'static str {
    // Single-cell glyph cycle (loop).
    //
    // Note: don't reuse `spinner_step` directly (275ms cadence). We derive a
    // faster tick from "approx elapsed time" so this animates smoothly without
    // speeding up other UI animations (border, etc.).
    const FRAMES: &[&str] = &["⣤", "⣰", "⢸", "⠹", "⠛", "⠏", "⡇", "⣆"];
    const TYPING_INTERVAL_MS: u128 = 60;

    let now = Instant::now();
    let elapsed_ms = (spinner_step as u128) * (SPINNER_INTERVAL_MS as u128)
        + now.duration_since(spinner_last).as_millis();
    let idx = ((elapsed_ms / TYPING_INTERVAL_MS) % (FRAMES.len() as u128)) as usize;
    FRAMES[idx]
}

fn assistant_typing_style() -> Style {
    // Single solid color (no gradient).
    Style::default()
        .fg(c_muted())
        .add_modifier(Modifier::BOLD)
}

pub fn remove_typing_placeholder(app: &mut App) {
    if let Some(idx) = app
        .messages
        .iter()
        .rposition(|m| m.role == ChatRole::Assistant && !m.sendable)
    {
        app.messages.remove(idx);
    }
}

fn format_bytes(bytes: u64) -> String {
    if bytes < 1024 {
        return format!("{bytes} B");
    }
    let kb = (bytes as f64) / 1024.0;
    if kb < 1024.0 {
        return format!("{:.1} KB", kb);
    }
    let mb = kb / 1024.0;
    format!("{:.1} MB", mb)
}

pub fn format_dir_listing_for_user(result: &Value) -> Option<String> {
    let path = result
        .get("path")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or(".");
    let entries = result.get("entries")?.as_array()?;
    let truncated = result.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false);

    let mut dirs: Vec<(String, Option<u64>)> = Vec::new();
    let mut files: Vec<(String, Option<u64>)> = Vec::new();
    let mut other: Vec<(String, Option<u64>)> = Vec::new();

    for e in entries {
        let name = e
            .get("name")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .unwrap_or("");
        if name.is_empty() {
            continue;
        }
        let typ = e
            .get("type")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .unwrap_or("other");
        let bytes = e.get("bytes").and_then(|v| v.as_u64());

        match typ {
            "dir" => dirs.push((name.to_string(), bytes)),
            "file" => files.push((name.to_string(), bytes)),
            _ => other.push((name.to_string(), bytes)),
        }
    }

    let mut out: Vec<String> = Vec::new();
    out.push("Auto tool: file.dir".to_string());
    out.push(String::new());
    out.push(format!(
        "Directory listing for `{}` ({} entries{}):",
        path,
        entries.len(),
        if truncated { ", truncated" } else { "" }
    ));

    let mut show_group = |label: &str, arr: &[(String, Option<u64>)], is_dir: bool| {
        if arr.is_empty() {
            return;
        }
        out.push(String::new());
        out.push(format!("{label}:"));
        for (name, bytes) in arr.iter().take(50) {
            let mut line = String::new();
            line.push_str("- ");
            line.push_str(name);
            if is_dir && !name.ends_with('/') {
                line.push('/');
            }
            if let Some(b) = bytes {
                line.push_str(&format!(" ({})", format_bytes(*b)));
            }
            out.push(line);
        }
        if arr.len() > 50 {
            out.push("- ...".to_string());
        }
    };

    show_group("Folders", &dirs, true);
    show_group("Files", &files, false);
    show_group("Other", &other, false);

    out.push(String::new());
    out.push(format!("Truncated: {}.", if truncated { "yes" } else { "no" }));

    Some(out.join("\n"))
}

fn truncate_chars(input: &str, max_chars: usize) -> (String, bool) {
    if max_chars == 0 {
        return (String::new(), input.chars().next().is_some());
    }
    let mut out = String::new();
    let mut count = 0usize;
    for ch in input.chars() {
        if count >= max_chars {
            return (out, true);
        }
        out.push(ch);
        count += 1;
    }
    (out, false)
}

pub fn format_file_read_for_user(result: &Value) -> Option<String> {
    let path = result
        .get("path")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .unwrap_or("-");
    let detected = result
        .get("detectedType")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .unwrap_or("text");
    let truncated = result.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false);
    let total_bytes = result.get("totalBytes").and_then(|v| v.as_u64()).unwrap_or(0);
    let line_start = result.get("lineStart").and_then(|v| v.as_u64()).unwrap_or(1);
    let line_end = result.get("lineEnd").and_then(|v| v.as_u64()).unwrap_or(1);
    let content = result.get("content")?.as_str().unwrap_or("");

    let (snippet, clipped) = truncate_chars(content, 8000);

    let mut out: Vec<String> = Vec::new();
    out.push("Auto tool: file.read".to_string());
    out.push(String::new());
    out.push(format!(
        "File: `{}` (type={}, lines {}-{}, bytes={}, truncated={})",
        path,
        detected,
        line_start,
        line_end,
        total_bytes,
        if truncated { "yes" } else { "no" }
    ));
    out.push(String::new());
    out.push(format!("```{detected}"));
    out.push(snippet);
    if clipped {
        out.push("\n…(truncated)".to_string());
    }
    out.push("```".to_string());

    Some(out.join("\n"))
}

pub fn format_tool_propose_result(tool_name: &str, payload: &Value) -> String {
    let requires_confirmation = payload
        .get("requiresConfirmation")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    if requires_confirmation {
        let preview = payload.get("preview").cloned().unwrap_or_else(|| json!({}));
        let preview_text =
            serde_json::to_string_pretty(&preview).unwrap_or_else(|_| preview.to_string());
        let (snippet, clipped) = truncate_chars(&preview_text, 8000);
        let mut out = vec![
            format!("Tool proposal (requires confirmation): {tool_name}"),
            String::new(),
            snippet,
        ];
        if clipped {
            out.push("…(truncated)".to_string());
        }
        out.push(String::new());
        out.push("This tool requires confirmation; choices only support safe tools.".to_string());
        return out.join("\n");
    }

    let run_id = payload
        .get("runId")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or("-");
    let result = payload.get("result").cloned().unwrap_or_else(|| json!({}));

    let mut out: Vec<String> = Vec::new();
    out.push(format!("Tool result: {tool_name} (runId: {run_id})"));
    out.push(String::new());

    if tool_name == "file.dir" {
        if let Some(listing) = format_dir_listing_for_user(&result) {
            out.push(listing);
        } else {
            out.push(result.to_string());
        }
        return out.join("\n");
    }

    if tool_name == "file.read" {
        if let Some(text) = format_file_read_for_user(&result) {
            out.push(text);
        } else {
            out.push(result.to_string());
        }
        return out.join("\n");
    }

    let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string());
    let (snippet, clipped) = truncate_chars(&text, 8000);
    out.push(snippet);
    if clipped {
        out.push("…(truncated)".to_string());
    }
    out.join("\n")
}

mod.rs

Path: starbot_tui/cli/src/parse/mod.rs

Size: 18 bytes

pub mod response;

response.rs

Path: starbot_tui/cli/src/parse/response.rs

Size: 2166 bytes

// PHASE 3: Shared response parsing functions
// Extracted from tui.rs and chat.rs to eliminate 60 lines of duplication

use serde_json::Value;

/// Extract the reply text from an API response
pub fn extract_reply(payload: &Value) -> Option<String> {
    if let Some(reply) = payload.get("reply").and_then(|v| v.as_str()) {
        return Some(reply.to_string());
    }
    payload
        .get("message")
        .and_then(|v| v.get("content"))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
}

/// Extract provider and model names from an API response
pub fn extract_provider_model(payload: &Value) -> (Option<String>, Option<String>) {
    let provider = payload
        .get("provider")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .or_else(|| {
            payload
                .get("message")
                .and_then(|v| v.get("provider"))
                .and_then(|v| v.as_str())
                .map(|s| s.to_string())
        });
    let model = payload
        .get("model")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .or_else(|| {
            payload
                .get("message")
                .and_then(|v| v.get("model"))
                .and_then(|v| v.as_str())
                .map(|s| s.to_string())
        });
    (provider, model)
}

/// Extract usage statistics and format as a display string
pub fn extract_usage_line(payload: &Value) -> String {
    if let Some(usage) = payload.get("usage") {
        let input = usage
            .get("inputTokens")
            .or_else(|| usage.get("input_tokens"))
            .and_then(|v| v.as_u64())
            .unwrap_or(0);
        let output = usage
            .get("outputTokens")
            .or_else(|| usage.get("output_tokens"))
            .and_then(|v| v.as_u64())
            .unwrap_or(0);
        let total = usage
            .get("totalTokens")
            .or_else(|| usage.get("total_tokens"))
            .and_then(|v| v.as_u64())
            .unwrap_or(input + output);
        return format!("usage(input={input}, output={output}, total={total})");
    }
    "usage(unknown)".to_string()
}

smoke.sh

Path: starbot_tui/scripts/smoke.sh

Size: 192 bytes

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"

"${SCRIPT_DIR}/starbott-dev.sh" whoami
echo
"${SCRIPT_DIR}/starbott-dev.sh" chat "hi"

starbott-dev.sh

Path: starbot_tui/scripts/starbott-dev.sh

Size: 1404 bytes

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CLI_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"

API_URL="${STARBOTT_API_URL:-http://127.0.0.1:3003}"

need_cmd() {
  command -v "$1" >/dev/null 2>&1 || {
    echo "Missing required command: $1" >&2
    exit 3
  }
}

need_cmd curl

BIN="${CLI_DIR}/target/debug/starbott"
if [[ ! -x "$BIN" ]]; then
  # Prefer offline build (this environment often blocks crates.io).
  (cd "$CLI_DIR" && cargo build --offline >/dev/null) || (cd "$CLI_DIR" && cargo build >/dev/null)
fi

# Sanity check: make sure API is reachable before launching a TUI.
if ! curl -fsS --max-time 15 "${API_URL}/health" >/dev/null 2>&1; then
  echo "API not reachable at ${API_URL}." >&2
  echo "Start the canonical STARBOT API, then re-run this script." >&2
  exit 2
fi

# Dev-only shortcut: allow a direct token override via STARBOTT_TOKEN.
if [[ -z "${STARBOTT_TOKEN:-}" ]]; then
  if [[ -n "${CI:-}" ]]; then
    echo "Not authenticated (CI mode). Set STARBOTT_TOKEN or run: ${BIN} --api-url ${API_URL} auth login" >&2
    exit 2
  fi

  # If the user already has a saved token in their CLI profile, whoami will succeed.
  if ! "$BIN" --api-url "$API_URL" whoami >/dev/null 2>&1; then
    echo "Not authenticated. Starting device login..." >&2
    "$BIN" --api-url "$API_URL" auth login
  fi
fi

exec "$BIN" --api-url "$API_URL" "$@"

output.rs

Path: starbot_tui/src/output.rs

Size: 1300 bytes

use serde::Serialize;

use crate::errors::CliError;

#[derive(Debug, Clone)]
pub struct OutputMode {
    pub json: bool,
    pub quiet: bool,
    pub verbose: bool,
    pub debug: bool,
}

impl OutputMode {
    pub fn print_json<T: Serialize>(&self, value: &T) -> Result<(), CliError> {
        let text = serde_json::to_string(value)?;
        println!("{text}");
        Ok(())
    }

    pub fn print_human(&self, message: &str) {
        if self.json || self.quiet {
            return;
        }
        println!("{message}");
    }

    pub fn print_stderr(&self, message: &str) {
        if self.json || self.quiet {
            return;
        }
        eprintln!("{message}");
    }

    pub fn print_verbose(&self, message: &str) {
        if !self.verbose || self.json || self.quiet {
            return;
        }
        eprintln!("{message}");
    }
}

pub fn print_error(error: &CliError, mode: &OutputMode) {
    if mode.json {
        let payload = serde_json::json!({
            "error": error.to_string(),
            "code": error.exit_code()
        });
        println!(
            "{}",
            serde_json::to_string(&payload)
                .unwrap_or_else(|_| "{\"error\":\"unknown\"}".to_string())
        );
        return;
    }

    eprintln!("Error: {error}");
}

api.rs

Path: starbot_tui/src/api.rs

Size: 10412 bytes

use std::time::{Duration, Instant};

use reqwest::{Client, Method, StatusCode};
use serde_json::{Value, json};
use tokio::time::sleep;
use tokio::sync::mpsc;
use futures::StreamExt;

use crate::errors::{CliError, redact_secret, with_debug_hint};

#[derive(Debug, Clone)]
pub struct ApiClient {
    client: Client,
    base_url: String,
    token: Option<String>,
    retries: u32,
    debug: bool,
}

#[derive(Debug, Clone)]
pub struct ApiResponse {
    pub request_id: Option<String>,
    pub elapsed_ms: u128,
    pub json: Value,
}

impl ApiClient {
    pub fn new(
        base_url: String,
        token: Option<String>,
        timeout_ms: u64,
        retries: u32,
        debug: bool,
    ) -> Result<Self, CliError> {
        let timeout = Duration::from_millis(timeout_ms.max(1));
        let client = Client::builder().timeout(timeout).build()?;
        Ok(Self {
            client,
            base_url,
            token,
            retries,
            debug,
        })
    }

    pub async fn get_json(
        &self,
        path: &str,
        query: Option<&[(String, String)]>,
        auth_required: bool,
    ) -> Result<ApiResponse, CliError> {
        self.request_json(Method::GET, path, query, None, auth_required, true)
            .await
    }

    pub async fn post_json(
        &self,
        path: &str,
        body: Option<Value>,
        auth_required: bool,
    ) -> Result<ApiResponse, CliError> {
        self.request_json(Method::POST, path, None, body, auth_required, false)
            .await
    }

    pub async fn post_json_with_body(
        &self,
        path: &str,
        body: &Value,
        auth_required: bool,
    ) -> Result<ApiResponse, CliError> {
        self.request_json(Method::POST, path, None, Some(body.clone()), auth_required, false)
            .await
    }

    pub async fn request_json(
        &self,
        method: Method,
        path: &str,
        query: Option<&[(String, String)]>,
        body: Option<Value>,
        auth_required: bool,
        idempotent: bool,
    ) -> Result<ApiResponse, CliError> {
        let token = if auth_required {
            Some(self.token.clone().ok_or_else(|| {
                CliError::Auth("Missing token. Run `starbott auth login` first.".to_string())
            })?)
        } else {
            self.token.clone()
        };

        let url = join_url(&self.base_url, path);
        let max_attempts = if idempotent {
            self.retries.saturating_add(1)
        } else {
            1
        };

        for attempt in 0..max_attempts {
            let started = Instant::now();
            let mut request = self.client.request(method.clone(), url.clone());

            if let Some(query_items) = query {
                request = request.query(query_items);
            }

            if let Some(ref bearer) = token {
                request = request.bearer_auth(bearer);
            }

            if let Some(ref payload) = body {
                request = request.json(payload);
            }

            let response = request.send().await;
            match response {
                Ok(resp) => {
                    let status = resp.status();
                    let request_id = resp
                        .headers()
                        .get("x-request-id")
                        .and_then(|v| v.to_str().ok())
                        .map(|s| s.to_string());

                    let text = resp.text().await.unwrap_or_default();
                    if is_retryable_status(status) && idempotent && attempt + 1 < max_attempts {
                        sleep(backoff_delay_ms(attempt)).await;
                        continue;
                    }

                    let parsed = if text.trim().is_empty() {
                        json!({})
                    } else {
                        serde_json::from_str::<Value>(&text)
                            .unwrap_or_else(|_| json!({ "raw": text }))
                    };

                    if status.is_success() {
                        return Ok(ApiResponse {
                            request_id,
                            elapsed_ms: started.elapsed().as_millis(),
                            json: parsed,
                        });
                    }

                    return Err(self.http_error(status, request_id, parsed));
                }
                Err(err) => {
                    let transient = err.is_timeout() || err.is_connect() || err.is_request();
                    if transient && idempotent && attempt + 1 < max_attempts {
                        sleep(backoff_delay_ms(attempt)).await;
                        continue;
                    }

                    let message = if err.is_timeout() {
                        "Request timed out.".to_string()
                    } else {
                        format!("Network request failed: {err}")
                    };
                    return Err(CliError::Network(with_debug_hint(&message, self.debug)));
                }
            }
        }

        Err(CliError::Network(with_debug_hint(
            "Request failed after retries.",
            self.debug,
        )))
    }

    fn http_error(
        &self,
        status: StatusCode,
        request_id: Option<String>,
        payload: Value,
    ) -> CliError {
        let message = payload
            .get("error")
            .and_then(|v| v.as_str())
            .or_else(|| payload.get("message").and_then(|v| v.as_str()))
            .map(|s| s.to_string())
            .unwrap_or_else(|| format!("Request failed with status {}", status.as_u16()));

        let mut details = message;
        if let Some(id) = request_id {
            details.push_str(&format!(" (request_id: {id})"));
        }
        if self.debug {
            let mut payload_text = payload.to_string();
            if let Some(token) = &self.token {
                payload_text = payload_text.replace(token, &redact_secret(token));
            }
            details.push_str(&format!(" payload={payload_text}"));
        } else {
            details = with_debug_hint(&details, false);
        }

        match status.as_u16() {
            400 => CliError::Usage(details),
            401 | 403 => CliError::Auth(details),
            429 => CliError::RateLimited(details),
            500..=599 => CliError::Server(details),
            _ => CliError::Generic(details),
        }
    }

    /// Start a streaming POST request (SSE format)
    /// Returns a receiver channel that yields (event_type, data) tuples
    pub async fn post_stream(
        &self,
        path: &str,
        body: Option<Value>,
        auth_required: bool,
    ) -> Result<mpsc::UnboundedReceiver<StreamEvent>, CliError> {
        let token = if auth_required {
            Some(self.token.clone().ok_or_else(|| {
                CliError::Auth("Missing token. Run `starbott auth login` first.".to_string())
            })?)
        } else {
            self.token.clone()
        };

        let url = join_url(&self.base_url, path);
        let mut request = self.client.post(url);

        if let Some(token_str) = token {
            request = request.header("Authorization", format!("Bearer {}", token_str));
        }

        if let Some(body_val) = body {
            request = request.json(&body_val);
        }

        let response = request.send().await.map_err(|e| {
            CliError::Network(format!("Stream request failed: {}", e))
        })?;

        if !response.status().is_success() {
            let status = response.status();
            let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
            return Err(self.http_error(status, None, payload));
        }

        let (tx, rx) = mpsc::unbounded_channel();

        // Spawn a task to process the stream
        tokio::spawn(async move {
            let mut stream = response.bytes_stream();
            let mut buffer = String::new();
            let mut current_event = String::new();

            while let Some(chunk_result) = stream.next().await {
                match chunk_result {
                    Ok(chunk) => {
                        if let Ok(text) = std::str::from_utf8(&chunk) {
                            buffer.push_str(text);

                            // Process complete lines
                            while let Some(newline_pos) = buffer.find('\n') {
                                let line = buffer[..newline_pos].trim().to_string();
                                buffer = buffer[newline_pos + 1..].to_string();

                                if line.is_empty() {
                                    // Empty line signals end of event
                                    current_event.clear();
                                } else if let Some(event_type) = line.strip_prefix("event: ") {
                                    current_event = event_type.to_string();
                                } else if let Some(data) = line.strip_prefix("data: ") {
                                    if !current_event.is_empty() {
                                        let _ = tx.send(StreamEvent {
                                            event_type: current_event.clone(),
                                            data: data.to_string(),
                                        });
                                    }
                                }
                            }
                        }
                    }
                    Err(_) => break,
                }
            }
        });

        Ok(rx)
    }
}

#[derive(Debug, Clone)]
pub struct StreamEvent {
    pub event_type: String,
    pub data: String,
}

fn join_url(base_url: &str, path: &str) -> String {
    if path.starts_with("http://") || path.starts_with("https://") {
        return path.to_string();
    }
    format!(
        "{}/{}",
        base_url.trim_end_matches('/'),
        path.trim_start_matches('/')
    )
}

fn is_retryable_status(status: StatusCode) -> bool {
    matches!(
        status,
        StatusCode::TOO_MANY_REQUESTS
            | StatusCode::BAD_GATEWAY
            | StatusCode::SERVICE_UNAVAILABLE
            | StatusCode::GATEWAY_TIMEOUT
    )
}

fn backoff_delay_ms(attempt: u32) -> Duration {
    let pow = attempt.min(6);
    let factor = 1u64 << pow;
    Duration::from_millis(200 * factor)
}

app.rs

Path: starbot_tui/src/app.rs

Size: 1203 bytes

use std::path::PathBuf;

use crate::api::ApiClient;
use crate::config::{CliConfig, active_profile_name, resolve_api_url, resolve_token};
use crate::errors::CliError;
use crate::output::OutputMode;

#[derive(Debug, Clone)]
pub struct Runtime {
    pub output: OutputMode,
    pub config: CliConfig,
    pub config_path: PathBuf,
    pub profile_override: Option<String>,
    pub api_url_override: Option<String>,
    pub timeout_ms: u64,
    pub retries: u32,
}

impl Runtime {
    pub fn active_profile(&self) -> String {
        active_profile_name(&self.config, self.profile_override.as_deref())
    }

    pub fn resolved_api_url(&self) -> Result<String, CliError> {
        resolve_api_url(
            &self.config,
            &self.active_profile(),
            self.api_url_override.as_deref(),
        )
    }

    pub fn resolved_token(&self) -> Option<String> {
        resolve_token(&self.config, &self.active_profile())
    }

    pub fn api_client(&self) -> Result<ApiClient, CliError> {
        ApiClient::new(
            self.resolved_api_url()?,
            self.resolved_token(),
            self.timeout_ms,
            self.retries,
            self.output.debug,
        )
    }
}

config.rs

Path: starbot_tui/src/config.rs

Size: 3963 bytes

use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use url::Url;

use crate::errors::CliError;

pub const DEFAULT_API_URL: &str = "http://localhost:3737";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileConfig {
    pub api_url: String,
    pub token: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub refresh_token: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workspace_id: Option<String>,
}

impl Default for ProfileConfig {
    fn default() -> Self {
        Self {
            api_url: DEFAULT_API_URL.to_string(),
            token: None,
            refresh_token: None,
            workspace_id: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliConfig {
    pub profile: String,
    pub profiles: HashMap<String, ProfileConfig>,
}

impl Default for CliConfig {
    fn default() -> Self {
        let mut profiles = HashMap::new();
        profiles.insert("default".to_string(), ProfileConfig::default());
        Self {
            profile: "default".to_string(),
            profiles,
        }
    }
}

pub fn config_path() -> Result<PathBuf, CliError> {
    let base = dirs::config_dir().ok_or_else(|| {
        CliError::Generic("Could not resolve config directory for this OS.".to_string())
    })?;
    Ok(base.join("starbott").join("config.json"))
}

pub fn load_config() -> Result<CliConfig, CliError> {
    let path = config_path()?;
    if !path.exists() {
        return Ok(CliConfig::default());
    }

    let text = fs::read_to_string(&path)?;
    let mut config: CliConfig = serde_json::from_str(&text)?;
    let profile = config.profile.clone();
    ensure_profile(&mut config, &profile);
    Ok(config)
}

pub fn save_config(config: &CliConfig) -> Result<PathBuf, CliError> {
    let path = config_path()?;
    let parent = path
        .parent()
        .ok_or_else(|| CliError::Generic("Invalid config path.".to_string()))?;
    fs::create_dir_all(parent)?;
    fs::write(&path, serde_json::to_string_pretty(config)?)?;
    Ok(path)
}

pub fn active_profile_name(config: &CliConfig, profile_override: Option<&str>) -> String {
    profile_override
        .map(|s| s.to_string())
        .unwrap_or_else(|| config.profile.clone())
}

pub fn ensure_profile(config: &mut CliConfig, profile_name: &str) {
    if !config.profiles.contains_key(profile_name) {
        config
            .profiles
            .insert(profile_name.to_string(), ProfileConfig::default());
    }
}

pub fn profile_ref<'a>(config: &'a CliConfig, profile_name: &str) -> Option<&'a ProfileConfig> {
    config.profiles.get(profile_name)
}

pub fn profile_mut<'a>(
    config: &'a mut CliConfig,
    profile_name: &str,
) -> Option<&'a mut ProfileConfig> {
    config.profiles.get_mut(profile_name)
}

pub fn resolve_api_url(
    config: &CliConfig,
    profile_name: &str,
    api_override: Option<&str>,
) -> Result<String, CliError> {
    if let Some(url) = api_override {
        validate_url(url)?;
        return Ok(url.to_string());
    }

    let profile = profile_ref(config, profile_name)
        .ok_or_else(|| CliError::Usage(format!("Profile '{profile_name}' does not exist.")))?;
    validate_url(&profile.api_url)?;
    Ok(profile.api_url.clone())
}

pub fn resolve_token(config: &CliConfig, profile_name: &str) -> Option<String> {
    if let Ok(token) = std::env::var("STARBOTT_TOKEN") {
        if !token.trim().is_empty() {
            return Some(token.trim().to_string());
        }
    }

    profile_ref(config, profile_name).and_then(|p| p.token.clone())
}

pub fn validate_url(value: &str) -> Result<(), CliError> {
    let parsed = Url::parse(value)?;
    if parsed.scheme() != "http" && parsed.scheme() != "https" {
        return Err(CliError::Usage(
            "API URL must use http:// or https://.".to_string(),
        ));
    }
    Ok(())
}

main.rs

Path: starbot_tui/src/main.rs

Size: 3485 bytes

mod api;
mod app;
mod commands;
mod config;
mod cute;
mod errors;
mod output;
mod parse;
mod tui;

use clap::{Parser, Subcommand};

use crate::app::Runtime;
use crate::commands::auth::AuthCommand;
use crate::commands::billing::BillingCommand;
use crate::commands::chat::ChatArgs;
use crate::commands::config::ConfigCommand;
use crate::commands::tools::ToolsCommand;
use crate::commands::tui::TuiArgs;
use crate::commands::usage::UsageArgs;
use crate::errors::CliError;
use crate::output::{OutputMode, print_error};
use crate::commands::workspaces::WorkspaceCommand;

#[derive(Debug, Parser)]
#[command(
    name = "starbott",
    version,
    about = "Starbot CLI for chat, usage, billing, and account operations."
)]
struct Cli {
    #[arg(long, global = true)]
    profile: Option<String>,
    #[arg(long = "api-url", global = true)]
    api_url: Option<String>,
    #[arg(long, global = true)]
    json: bool,
    #[arg(long, global = true)]
    quiet: bool,
    #[arg(long, global = true, default_value_t = 30_000)]
    timeout: u64,
    #[arg(long, global = true, default_value_t = 2)]
    retries: u32,
    #[arg(long, global = true)]
    verbose: bool,
    #[arg(long, global = true)]
    debug: bool,
    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    Config {
        #[command(subcommand)]
        command: ConfigCommand,
    },
    Auth {
        #[command(subcommand)]
        command: AuthCommand,
    },
    Workspaces {
        #[command(subcommand)]
        command: WorkspaceCommand,
    },
    Tools {
        #[command(subcommand)]
        command: ToolsCommand,
    },
    Whoami,
    Chat(ChatArgs),
    Tui(TuiArgs),
    Usage(UsageArgs),
    Billing {
        #[command(subcommand)]
        command: BillingCommand,
    },
    Health,
}

#[tokio::main]
async fn main() {
    let cli = Cli::parse();
    let output = OutputMode {
        json: cli.json,
        quiet: cli.quiet,
        verbose: cli.verbose,
        debug: cli.debug,
    };

    cute::print_banner(&output);

    let result = run(cli, output.clone()).await;
    if let Err(err) = result {
        print_error(&err, &output);
        std::process::exit(err.exit_code());
    }
}

async fn run(cli: Cli, output: OutputMode) -> Result<(), CliError> {
    let config = config::load_config()?;
    let config_path = config::config_path()?;

    let mut runtime = Runtime {
        output,
        config,
        config_path,
        profile_override: cli.profile,
        api_url_override: cli.api_url,
        timeout_ms: cli.timeout,
        retries: cli.retries,
    };

    match cli.command {
        Commands::Config { command } => commands::config::handle(&mut runtime, command).await,
        Commands::Auth { command } => commands::auth::handle(&mut runtime, command).await,
        Commands::Workspaces { command } => commands::workspaces::handle(&runtime, command).await,
        Commands::Tools { command } => commands::tools::handle(&runtime, command).await,
        Commands::Whoami => commands::whoami::handle(&runtime).await,
        Commands::Chat(args) => commands::chat::handle(&runtime, args).await,
        Commands::Tui(args) => commands::tui::handle(&runtime, args).await,
        Commands::Usage(args) => commands::usage::handle(&runtime, args).await,
        Commands::Billing { command } => commands::billing::handle(&runtime, command).await,
        Commands::Health => commands::health::handle(&runtime).await,
    }
}

errors.rs

Path: starbot_tui/src/errors.rs

Size: 2377 bytes

use std::fmt;

use thiserror::Error;

#[derive(Debug, Clone, Copy)]
pub enum ExitCode {
    Generic = 1,
    Auth = 2,
    Usage = 3,
    Network = 4,
    RateLimited = 5,
    Server = 6,
}

#[derive(Debug, Error)]
pub enum CliError {
    #[error("{0}")]
    Usage(String),
    #[error("{0}")]
    Auth(String),
    #[error("{0}")]
    Network(String),
    #[error("{0}")]
    RateLimited(String),
    #[error("{0}")]
    Server(String),
    #[error("{0}")]
    Generic(String),
}

impl CliError {
    pub fn exit_code(&self) -> i32 {
        match self {
            CliError::Usage(_) => ExitCode::Usage as i32,
            CliError::Auth(_) => ExitCode::Auth as i32,
            CliError::Network(_) => ExitCode::Network as i32,
            CliError::RateLimited(_) => ExitCode::RateLimited as i32,
            CliError::Server(_) => ExitCode::Server as i32,
            CliError::Generic(_) => ExitCode::Generic as i32,
        }
    }
}

impl From<std::io::Error> for CliError {
    fn from(value: std::io::Error) -> Self {
        CliError::Generic(format!("I/O error: {value}"))
    }
}

impl From<serde_json::Error> for CliError {
    fn from(value: serde_json::Error) -> Self {
        CliError::Generic(format!("JSON error: {value}"))
    }
}

impl From<url::ParseError> for CliError {
    fn from(value: url::ParseError) -> Self {
        CliError::Usage(format!("Invalid URL: {value}"))
    }
}

impl From<reqwest::Error> for CliError {
    fn from(value: reqwest::Error) -> Self {
        if value.is_timeout() {
            return CliError::Network("Request timed out.".to_string());
        }
        CliError::Network(format!("Network request failed: {value}"))
    }
}

pub fn with_debug_hint(message: &str, debug: bool) -> String {
    if debug {
        return message.to_string();
    }
    format!("{message} (try --debug for details)")
}

pub fn redact_secret(input: &str) -> String {
    if input.is_empty() {
        return String::new();
    }
    let mut out = String::new();
    let bytes = input.as_bytes();
    for (idx, b) in bytes.iter().enumerate() {
        if idx < 3 || idx + 3 >= bytes.len() {
            out.push(*b as char);
        } else {
            out.push('*');
        }
    }
    out
}

impl fmt::Display for ExitCode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", *self as i32)
    }
}

cute.rs

Path: starbot_tui/src/cute.rs

Size: 2043 bytes

use std::fs;
use std::path::PathBuf;

use crate::output::OutputMode;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CuteMode {
    On,
    Minimal,
    Off,
}

impl CuteMode {
    pub fn parse(value: &str) -> Option<Self> {
        match value.trim().to_ascii_lowercase().as_str() {
            "on" => Some(Self::On),
            "minimal" => Some(Self::Minimal),
            "off" => Some(Self::Off),
            _ => None,
        }
    }
}

pub fn print_banner(mode: &OutputMode) {
    if mode.json || mode.quiet {
        return;
    }

    if load_cute_mode() != CuteMode::On {
        return;
    }

    let banner = r#"
      ★   *      .       .   *   .   *
   .     *      .    ★     .      .
        ███████╗████████╗ █████╗ ██████╗ ██████╗  ██████╗ ████████╗████████╗
        ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██╔══██╗██╔═══██╗╚══██╔══╝╚══██╔══╝
        ███████╗   ██║   ███████║██████╔╝██████╔╝██║   ██║   ██║      ██║
        ╚════██║   ██║   ██╔══██║██╔══██╗██╔══██╗██║   ██║   ██║      ██║
  ★     ███████║   ██║   ██║  ██║██║  ██║██████╔╝╚██████╔╝   ██║      ██║    ★
        ╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝╚═════╝  ╚═════╝    ╚═╝      ╚═╝
    .    *    .   *      .   *   .   *
"#;
    eprintln!("{}", banner);
}

pub fn cute_config_path() -> Option<PathBuf> {
    let home = dirs::home_dir()?;
    Some(home.join(".starbott").join("config"))
}

pub fn load_cute_mode() -> CuteMode {
    let Some(path) = cute_config_path() else {
        return CuteMode::On;
    };

    let Ok(raw) = fs::read_to_string(path) else {
        return CuteMode::On;
    };

    for line in raw.lines() {
        let l = line.trim();
        if l.is_empty() || l.starts_with('#') {
            continue;
        }

        let (key, value) = match l.split_once('=') {
            Some(kv) => kv,
            None => continue,
        };

        if key.trim().eq_ignore_ascii_case("cute") {
            if let Some(mode) = CuteMode::parse(value) {
                return mode;
            }
        }
    }

    CuteMode::On
}

mod.rs

Path: starbot_tui/src/tui/mod.rs

Size: 213 bytes

// PHASE 3: TUI module - refactored from monolithic tui.rs

pub mod types;
pub mod handlers;

pub use types::*;
pub use handlers::*;

// Re-export main entry point
pub use crate::commands::tui::{TuiArgs, handle};

types.rs

Path: starbot_tui/src/tui/types.rs

Size: 8962 bytes

// PHASE 3: TUI type definitions extracted from tui.rs
// Contains all structs, enums, and their implementations

use std::time::Instant;
use ratatui::widgets::ListState;
use serde_json::Value;

use crate::config::CliConfig;
use crate::cute::CuteMode;
use crate::api::ApiResponse;
use crate::errors::CliError;

// ============================================================================
// Request routing types
// ============================================================================

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lane {
    Quick,
    Standard,
    Deep,
}

impl Lane {
    pub fn from_str(value: &str) -> Option<Self> {
        match value.trim().to_ascii_lowercase().as_str() {
            "quick" => Some(Self::Quick),
            "standard" => Some(Self::Standard),
            "deep" => Some(Self::Deep),
            _ => None,
        }
    }
}

// ============================================================================
// Model and workspace picker types
// ============================================================================

#[derive(Debug, Clone)]
pub struct ModelOption {
    pub provider: String,
    pub model: Option<String>,
    pub label: String,
}

#[derive(Debug, Clone)]
pub struct WorkspaceOption {
    pub id: String,
    pub name: String,
    pub root_path: Option<String>,
    pub archived: bool,
    pub last_used_at: Option<String>,
}

#[derive(Debug, Clone)]
pub struct ThreadOption {
    pub id: String,
    pub title: String,
    pub mode: Option<String>,
    pub last_message_at: Option<String>,
    pub is_pinned: bool,
    pub message_count: usize,
}

// ============================================================================
// Memory management types (Phase 5)
// ============================================================================

#[derive(Debug, Clone)]
pub struct MemoryItem {
    pub id: String,
    pub scope: String,       // "global" or "project"
    pub project_id: Option<String>,
    pub item_type: String,   // "fact", "preference", "decision", "constraint", "todo"
    pub content: String,
    pub tags: Vec<String>,
    pub salience: f64,       // 0.0-1.0 importance score
    pub confidence: f64,     // 0.0-1.0 confidence score
    pub source: String,      // "manual", "auto", etc.
    pub enabled: bool,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Debug, Clone)]
pub struct MemorySettings {
    pub enabled: bool,
    pub allow_auto_capture: bool,
    pub max_context_tokens: usize,
    pub max_items_injected: usize,
    pub include_global: bool,
    pub include_project: bool,
}

impl Default for MemorySettings {
    fn default() -> Self {
        Self {
            enabled: true,
            allow_auto_capture: true,
            max_context_tokens: 600,
            max_items_injected: 12,
            include_global: true,
            include_project: true,
        }
    }
}

// ============================================================================
// Choice prompt modal types (SPEC17)
// ============================================================================

#[derive(Debug, Clone)]
pub enum ChoiceAction {
    SetWorkspace { workspace_id: String },
    Tool { tool_name: String, input: Value },
    Input { prompt: String },
    SendMessage { text: String },
}

#[derive(Debug, Clone)]
pub struct ChoiceOption {
    pub id: String,
    pub label: String,
    pub description: String,
    pub action: ChoiceAction,
}

#[derive(Debug, Clone)]
pub struct ChoicePrompt {
    pub id: String,
    pub title: String,
    pub hint: String,
    pub allow_custom: bool,
    pub custom_placeholder: String,
    pub options: Vec<ChoiceOption>,
}

#[derive(Debug, Clone)]
pub struct TextPromptState {
    pub prompt: String,
    pub input: Vec<char>,
    pub cursor: usize,
}

// ============================================================================
// Chat message types
// ============================================================================

#[derive(Debug, Clone)]
pub struct ChatMsg {
    pub role: ChatRole,
    pub content: String,
    pub sendable: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChatRole {
    User,
    Assistant,
    System,
}

impl ChatRole {
    pub fn as_str(self) -> &'static str {
        match self {
            ChatRole::User => "user",
            ChatRole::Assistant => "assistant",
            ChatRole::System => "system",
        }
    }
}

// ============================================================================
// UI mode enum
// ============================================================================

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Chat,
    ModelPicker,
    WorkspacePicker,
    ThreadPicker,
    MemoryPanel,
    ChoiceModal,
    TextPromptModal,
    Help,
    ToolCard,
}

// ============================================================================
// Provider health hints
// ============================================================================

#[derive(Debug, Clone, Copy)]
pub struct ProviderHints {
    pub vertex_ok: Option<bool>,
    pub azure_present: bool,
    pub cf_present: bool,
}

// ============================================================================
// Tool approval types
// ============================================================================

#[derive(Debug, Clone)]
pub struct PendingToolCard {
    pub tool_name: String,
    pub target_files: Vec<String>,
    pub preview: String,
    pub requires_confirmation: bool,
}

#[derive(Debug, Clone)]
pub struct ToolApprovalEntry {
    pub tool_name: String,
    pub approved: bool,
}

// ============================================================================
// Main App state
// ============================================================================

#[derive(Debug)]
pub struct App {
    pub mode: Mode,
    pub should_quit: bool,

    pub api_url: String,
    pub config: CliConfig,
    pub profile: String,
    pub token_present: bool,
    pub cute: CuteMode,
    pub rng: u64,
    pub last_phrase: Option<&'static str>,
    pub success_count: u32,
    pub lane: Option<Lane>,
    pub hints: ProviderHints,
    pub spinner_step: u64,
    pub spinner_last: Instant,
    pub bg_tasks: u32,

    pub messages: Vec<ChatMsg>,
    pub input: Vec<char>,
    pub cursor: usize,
    pub waiting: bool,

    pub status: String,
    pub last_request_id: Option<String>,
    pub last_elapsed_ms: Option<u128>,
    pub last_provider: Option<String>,
    pub last_model: Option<String>,
    pub last_usage: Option<String>,

    pub activity_lines: Vec<String>,
    pub current_file: Option<String>,
    pub auto_edits: bool,
    pub working_dir: String,

    pub model_options: Vec<ModelOption>,
    pub model_state: ListState,
    pub selected_provider: String,
    pub selected_model: Option<String>,

    pub workspace_options: Vec<WorkspaceOption>,
    pub workspace_state: ListState,
    pub selected_workspace_id: Option<String>,
    pub selected_workspace_name: Option<String>,
    pub pending_workspace_retry: bool,

    // Thread management state (PHASE 5)
    pub thread_options: Vec<ThreadOption>,
    pub thread_state: ListState,
    pub active_thread_id: Option<String>,
    pub active_thread_title: Option<String>,

    // Memory management state (Phase 5)
    pub memory_items: Vec<MemoryItem>,
    pub memory_state: ListState,
    pub memory_settings: MemorySettings,
    pub memory_enabled: bool,  // Quick access to enabled status

    // Choice prompt modal state (SPEC17)
    pub choice_prompt: Option<ChoicePrompt>,
    pub choice_state: ListState,
    pub text_prompt: Option<TextPromptState>,

    pub scroll_from_bottom: usize,
    pub show_debug: bool,

    // Tool card state
    pub pending_tool: Option<PendingToolCard>,
    pub tool_approval_history: Vec<ToolApprovalEntry>,
}

// ============================================================================
// Async message enum
// ============================================================================

#[derive(Debug)]
pub enum TuiMsg {
    Models(Result<ApiResponse, CliError>),
    Health(Result<ApiResponse, CliError>),
    Workspaces(Result<ApiResponse, CliError>),
    Threads(Result<ApiResponse, CliError>),
    Memory(Result<ApiResponse, CliError>),
    MemorySettings(Result<ApiResponse, CliError>),
    Chat(Result<ApiResponse, CliError>),
    Tool(String, Result<ApiResponse, CliError>),
    // New Starbot_API messages
    Projects(Result<ApiResponse, CliError>),
    Chats(Result<ApiResponse, CliError>),
    Messages(Result<ApiResponse, CliError>),
    ProjectCreated(Result<ApiResponse, CliError>),
    ChatCreated(Result<ApiResponse, CliError>),
    MessageAdded(Result<ApiResponse, CliError>),
    ChatCancelled(Result<ApiResponse, CliError>),
    // Streaming events
    StreamStatus(String),
    StreamToken(String),
    StreamDone(serde_json::Value),
    StreamError(String),
}

message.rs

Path: starbot_tui/src/tui/handlers/message.rs

Size: 17749 bytes

// PHASE 3: TUI message handler extracted from tui.rs
// Contains handle_tui_msg function for processing async results

use tokio::sync::mpsc;

use crate::api::ApiClient;
use crate::tui::types::{App, TuiMsg, ChatMsg, ChatRole, Mode};
use crate::parse::response::{extract_reply, extract_provider_model, extract_usage_line};

// Import helper functions from parent tui module
// These remain in tui.rs for now (can be extracted to parse/format modules later)
use crate::commands::tui::{
    parse_model_options, find_selected_model_index,
    parse_vertex_ok, parse_workspace_options, parse_thread_options, parse_choice_prompt,
    parse_memory_items, parse_memory_settings,
    format_dir_listing_for_user, format_file_read_for_user,
    parse_lane, format_tool_propose_result,
    ready_status, format_success_status, format_error_status,
    remove_typing_placeholder,
};

use super::async_ops::spawn_workspaces_fetch;

pub fn handle_tui_msg(api: &ApiClient, tx: &mpsc::UnboundedSender<TuiMsg>, app: &mut App, msg: TuiMsg) {
    match msg {
        TuiMsg::Models(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
            Ok(resp) => {
                if let Some(options) = parse_model_options(&resp.json) {
                    if !options.is_empty() {
                        app.hints.azure_present = options.iter().any(|o| o.provider == "azure");
                        app.hints.cf_present = options.iter().any(|o| o.provider == "cloudflare");
                        app.model_options = options;
                        let idx = find_selected_model_index(
                            &app.model_options,
                            &app.selected_provider,
                            app.selected_model.as_deref(),
                        )
                        .unwrap_or(0);
                        app.model_state.select(Some(idx));
                        app.status = ready_status(app.cute, app.status.as_str()).to_string();
                    } else {
                        app.status = "No models returned by server.".to_string();
                    }
                } else {
                    app.status = "Failed parsing /v1/models response.".to_string();
                }
            }
            Err(err) => {
                app.status = format!("Failed loading models: {err}");
            }
        }
        }
        TuiMsg::Health(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
            Ok(resp) => {
                app.hints.vertex_ok = parse_vertex_ok(&resp.json);
            }
            Err(_) => {
                app.hints.vertex_ok = None;
            }
        }
        }
        TuiMsg::Workspaces(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
                Ok(resp) => {
                    if let Some(options) = parse_workspace_options(&resp.json) {
                        app.workspace_options = options;
                        if app.workspace_state.selected().is_none() && !app.workspace_options.is_empty() {
                            app.workspace_state.select(Some(0));
                        }

                        if let Some(ref sel_id) = app.selected_workspace_id {
                            if let Some(idx) = app
                                .workspace_options
                                .iter()
                                .position(|w| w.id == *sel_id)
                            {
                                app.workspace_state.select(Some(idx));
                                app.selected_workspace_name =
                                    Some(app.workspace_options[idx].name.clone());
                            } else {
                                app.selected_workspace_name = None;
                            }
                        }
                    } else {
                        app.status = "Failed parsing /v1/workspaces response.".to_string();
                    }
                }
                Err(err) => {
                    app.status = format!("Failed loading workspaces: {err}");
                }
            }
        }
        TuiMsg::Threads(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
                Ok(resp) => {
                    if let Some(options) = parse_thread_options(&resp.json) {
                        app.thread_options = options;
                        if app.thread_state.selected().is_none() && !app.thread_options.is_empty() {
                            app.thread_state.select(Some(0));
                        }

                        // Restore selection if we have an active thread
                        if let Some(ref thread_id) = app.active_thread_id {
                            if let Some(idx) = app
                                .thread_options
                                .iter()
                                .position(|t| t.id == *thread_id)
                            {
                                app.thread_state.select(Some(idx));
                                app.active_thread_title =
                                    Some(app.thread_options[idx].title.clone());
                            } else {
                                app.active_thread_title = None;
                            }
                        }
                    } else {
                        app.status = "Failed parsing /v1/threads response.".to_string();
                    }
                }
                Err(err) => {
                    app.status = format!("Failed loading threads: {err}");
                }
            }
        }
        TuiMsg::Memory(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
                Ok(resp) => {
                    if let Some(options) = parse_memory_items(&resp.json) {
                        app.memory_items = options;
                        if app.memory_state.selected().is_none() && !app.memory_items.is_empty() {
                            app.memory_state.select(Some(0));
                        }
                        app.status = format!("Loaded {} memory items", app.memory_items.len());
                    } else {
                        app.status = "Failed parsing /v1/memory response.".to_string();
                    }
                }
                Err(err) => {
                    app.status = format!("Failed loading memory: {err}");
                }
            }
        }
        TuiMsg::MemorySettings(res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            match res {
                Ok(resp) => {
                    if let Some(settings) = parse_memory_settings(&resp.json) {
                        app.memory_settings = settings;
                        app.memory_enabled = app.memory_settings.enabled;
                        app.status = format!(
                            "Memory: {} (max {} items)",
                            if app.memory_enabled { "enabled" } else { "disabled" },
                            app.memory_settings.max_items_injected
                        );
                    } else {
                        app.status = "Failed parsing /v1/memory/settings response.".to_string();
                    }
                }
                Err(err) => {
                    app.status = format!("Failed loading memory settings: {err}");
                }
            }
        }
        TuiMsg::Chat(res) => {
            app.waiting = false;
            remove_typing_placeholder(app);
            match res {
                Ok(resp) => {
                    app.last_request_id = resp.request_id.clone();
                    app.last_elapsed_ms = Some(resp.elapsed_ms);

                    let need_workspace = resp
                        .json
                        .get("needWorkspace")
                        .and_then(|v| v.as_bool())
                        .unwrap_or(false);
                    let choice_prompt = parse_choice_prompt(&resp.json);

                    if need_workspace && choice_prompt.is_none() {
                        if let Some(options) = parse_workspace_options(&resp.json) {
                            app.workspace_options = options;
                            if !app.workspace_options.is_empty() {
                                app.workspace_state.select(Some(0));
                            }
                        } else if app.token_present {
                            app.bg_tasks = app.bg_tasks.saturating_add(1);
                            spawn_workspaces_fetch(api.clone(), tx.clone());
                        }

                        let msg = extract_reply(&resp.json)
                            .unwrap_or_else(|| "Pick a workspace to use, then retry your request.".to_string());
                        app.messages.push(ChatMsg {
                            role: ChatRole::System,
                            content: msg,
                            sendable: false,
                        });
                        app.pending_workspace_retry = true;
                        app.mode = Mode::WorkspacePicker;
                        app.status = "Workspace required. Select one (F3).".to_string();
                        return;
                    }

                    // Show server auto-tools deterministically in the chat (UI-only).
                    if let Some(auto_tools) = resp.json.get("autoTools").and_then(|v| v.as_array())
                    {
                        for t in auto_tools {
                            let tool_name = t
                                .get("toolName")
                                .and_then(|v| v.as_str())
                                .unwrap_or("");
                            if tool_name == "file.dir" {
                                if let Some(result) = t.get("result") {
                                    if let Some(listing) = format_dir_listing_for_user(result) {
                                        app.messages.push(ChatMsg {
                                            role: ChatRole::System,
                                            content: listing,
                                            sendable: false,
                                        });
                                    }
                                }
                            } else if tool_name == "file.read" {
                                if let Some(result) = t.get("result") {
                                    if let Some(text) = format_file_read_for_user(result) {
                                        app.messages.push(ChatMsg {
                                            role: ChatRole::System,
                                            content: text,
                                            sendable: false,
                                        });
                                    }
                                }
                            }
                        }
                    }

                    app.lane = parse_lane(&resp.json);
                    app.success_count = app.success_count.saturating_add(1);

                    let reply =
                        extract_reply(&resp.json).unwrap_or_else(|| "(No text response)".to_string());
                    app.messages.push(ChatMsg {
                        role: ChatRole::Assistant,
                        content: reply,
                        sendable: true,
                    });

                    if let Some(cp) = choice_prompt {
                        let title = cp.title.clone();
                        let hint = if cp.hint.trim().is_empty() {
                            "Tab/↑↓ select • Enter choose • Esc cancel".to_string()
                        } else {
                            cp.hint.clone()
                        };
                        app.messages.push(ChatMsg {
                            role: ChatRole::System,
                            content: format!("Choice: {title}\n{hint}"),
                            sendable: false,
                        });
                        app.choice_prompt = Some(cp);
                        app.choice_state.select(Some(0));
                        app.mode = Mode::ChoiceModal;
                        app.status = "Choice prompt".to_string();
                        if need_workspace
                            || resp
                                .json
                                .get("error")
                                .and_then(|v| v.as_str())
                                .map(|s| s == "NO_WORKSPACE_SELECTED")
                                .unwrap_or(false)
                        {
                            app.pending_workspace_retry = true;
                        }
                    }

                    let (provider, model) = extract_provider_model(&resp.json);
                    app.last_provider = provider.clone();
                    app.last_model = model.clone();
                    app.activity_lines.push(format!(
                        "Completed: {} via {}",
                        model.as_deref().unwrap_or("-"),
                        provider.as_deref().unwrap_or("-"),
                    ));
                    let usage = extract_usage_line(&resp.json);
                    app.last_usage = Some(usage);
                    app.status = format_success_status(
                        app.cute,
                        &mut app.rng,
                        &mut app.last_phrase,
                        app.success_count,
                        app.lane,
                    );
                }
                Err(err) => {
                    app.messages.push(ChatMsg {
                        role: ChatRole::System,
                        content: format!("Error: {err}"),
                        sendable: false,
                    });
                    app.status =
                        format_error_status(app.cute, &mut app.rng, &mut app.last_phrase, &err);
                }
            }
        }
        TuiMsg::Tool(tool_name, res) => {
            app.bg_tasks = app.bg_tasks.saturating_sub(1);
            app.waiting = false;
            match res {
                Ok(resp) => {
                    let rendered = format_tool_propose_result(&tool_name, &resp.json);
                    app.messages.push(ChatMsg {
                        role: ChatRole::System,
                        content: rendered,
                        sendable: true,
                    });
                    app.status = format!("Tool completed: {tool_name}");
                }
                Err(err) => {
                    app.messages.push(ChatMsg {
                        role: ChatRole::System,
                        content: format!("Tool error ({tool_name}): {err}"),
                        sendable: false,
                    });
                    app.status = format!("Tool failed: {tool_name}");
                }
            }
        }
        TuiMsg::StreamToken(token) => {
            // Append token to the last assistant message (streaming)
            if let Some(last_msg) = app.messages.last_mut() {
                if last_msg.role == ChatRole::Assistant && !last_msg.sendable {
                    // This is the streaming placeholder message
                    if last_msg.content == "…" {
                        last_msg.content = token;
                    } else {
                        last_msg.content.push_str(&token);
                    }
                }
            }
        }
        TuiMsg::StreamDone(metadata) => {
            // Streaming complete - mark message as complete and extract metadata
            app.waiting = false;

            if let Some(last_msg) = app.messages.last_mut() {
                if last_msg.role == ChatRole::Assistant && !last_msg.sendable {
                    // Mark as sendable (complete)
                    last_msg.sendable = true;

                    // If still placeholder, replace with "(No response)"
                    if last_msg.content == "…" {
                        last_msg.content = "(No text response)".to_string();
                    }
                }
            }

            // Extract metadata
            let (provider, model) = extract_provider_model(&metadata);
            app.last_provider = provider.clone();
            app.last_model = model.clone();
            app.activity_lines.push(format!(
                "Completed: {} via {}",
                model.as_deref().unwrap_or("-"),
                provider.as_deref().unwrap_or("-"),
            ));

            let usage = extract_usage_line(&metadata);
            app.last_usage = Some(usage);

            app.lane = parse_lane(&metadata);
            app.success_count = app.success_count.saturating_add(1);
            app.status = format_success_status(
                app.cute,
                &mut app.rng,
                &mut app.last_phrase,
                app.success_count,
                app.lane,
            );
        }
        TuiMsg::StreamError(error) => {
            app.waiting = false;
            remove_typing_placeholder(app);

            app.messages.push(ChatMsg {
                role: ChatRole::System,
                content: format!("Streaming error: {error}"),
                sendable: false,
            });
            app.status = format_error_status(
                app.cute,
                &mut app.rng,
                &mut app.last_phrase,
                &crate::errors::CliError::Generic(error),
            );
        }
    }
}

mod.rs

Path: starbot_tui/src/tui/handlers/mod.rs

Size: 163 bytes

// PHASE 3: TUI handler modules extracted from tui.rs

pub mod async_ops;
pub mod key;
pub mod message;

pub use async_ops::*;
pub use key::*;
pub use message::*;

async_ops.rs

Path: starbot_tui/src/tui/handlers/async_ops.rs

Size: 9243 bytes

// Async operation handlers for Starbot_TUI
// Calls the local Starbot_API (localhost:3737)

use serde_json::{Value, json};
use tokio::sync::mpsc;

use crate::api::ApiClient;
use crate::tui::types::{TuiMsg, ChatMsg};

pub fn spawn_health_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        let res = api.get_json("/health", None, false).await;
        let _ = tx.send(TuiMsg::Health(res));
    });
}

pub fn spawn_projects_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        let res = api.get_json("/v1/projects", None, false).await;
        let _ = tx.send(TuiMsg::Projects(res));
    });
}

pub fn spawn_chats_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>, project_id: String) {
    tokio::spawn(async move {
        let path = format!("/v1/projects/{}/chats", project_id);
        let res = api.get_json(&path, None, false).await;
        let _ = tx.send(TuiMsg::Chats(res));
    });
}

pub fn spawn_messages_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>, chat_id: String) {
    tokio::spawn(async move {
        let path = format!("/v1/chats/{}/messages", chat_id);
        let res = api.get_json(&path, None, false).await;
        let _ = tx.send(TuiMsg::Messages(res));
    });
}

pub fn spawn_create_project(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>, name: String) {
    tokio::spawn(async move {
        let body = json!({ "name": name });
        let res = api.post_json("/v1/projects", Some(body), false).await;
        let _ = tx.send(TuiMsg::ProjectCreated(res));
    });
}

pub fn spawn_create_chat(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>, project_id: String, title: Option<String>) {
    tokio::spawn(async move {
        let path = format!("/v1/projects/{}/chats", project_id);
        let mut body = json!({});
        if let Some(t) = title {
            body["title"] = json!(t);
        }
        let res = api.post_json(&path, Some(body), false).await;
        let _ = tx.send(TuiMsg::ChatCreated(res));
    });
}

pub fn spawn_add_message(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>, chat_id: String, role: String, content: String) {
    tokio::spawn(async move {
        let path = format!("/v1/chats/{}/messages", chat_id);
        let body = json!({
            "role": role,
            "content": content,
        });
        let res = api.post_json(&path, Some(body), false).await;
        let _ = tx.send(TuiMsg::MessageAdded(res));
    });
}

/// Streaming chat request (SSE)
/// First adds user message to chat, then starts generation stream
pub fn spawn_chat_request_stream(
    api: ApiClient,
    tx: mpsc::UnboundedSender<TuiMsg>,
    chat_id: String,
    mode: String,
    speed: bool,
) {
    tokio::spawn(async move {
        let path = format!("/v1/chats/{}/run", chat_id);
        let body = json!({
            "mode": mode,
            "speed": speed,
            "auto": true,
        });

        match api.post_stream(&path, Some(body), false).await {
            Ok(mut rx) => {
                while let Some(event) = rx.recv().await {
                    match event.event_type.as_str() {
                        "status" => {
                            // Status update (routing, thinking, etc.)
                            if let Ok(data) = serde_json::from_str::<Value>(&event.data) {
                                if let Some(msg) = data.get("message").and_then(|v| v.as_str()) {
                                    let _ = tx.send(TuiMsg::StreamStatus(msg.to_string()));
                                }
                            }
                        }
                        "token.delta" => {
                            // Streaming token
                            if let Ok(data) = serde_json::from_str::<Value>(&event.data) {
                                if let Some(text) = data.get("text").and_then(|v| v.as_str()) {
                                    let _ = tx.send(TuiMsg::StreamToken(text.to_string()));
                                }
                            }
                        }
                        "message.final" => {
                            // Final assistant message
                            if let Ok(data) = serde_json::from_str::<Value>(&event.data) {
                                let _ = tx.send(TuiMsg::StreamDone(data));
                            }
                        }
                        "error" => {
                            // Error during generation
                            if let Ok(data) = serde_json::from_str::<Value>(&event.data) {
                                if let Some(msg) = data.get("message").and_then(|v| v.as_str()) {
                                    let _ = tx.send(TuiMsg::StreamError(msg.to_string()));
                                }
                            }
                        }
                        _ => {
                            // Ignore unknown events for now (tool.start, tool.end, etc.)
                        }
                    }
                }
            }
            Err(e) => {
                let _ = tx.send(TuiMsg::StreamError(e.to_string()));
            }
        }
    });
}

pub fn spawn_cancel_chat(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>, chat_id: String) {
    tokio::spawn(async move {
        let path = format!("/v1/chats/{}/cancel", chat_id);
        let res = api.post_json(&path, None, false).await;
        let _ = tx.send(TuiMsg::ChatCancelled(res));
    });
}

// Legacy functions kept for compatibility - these will be removed once TUI is fully refactored
// For now, they return empty/placeholder responses

pub fn spawn_models_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        // Models endpoint not yet implemented in Starbot_API
        // Return placeholder
        let placeholder = json!({
            "models": []
        });
        let _ = tx.send(TuiMsg::Models(Ok(crate::api::ApiResponse {
            request_id: None,
            elapsed_ms: 0,
            json: placeholder,
        })));
    });
}

pub fn spawn_workspaces_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    // Renamed to projects
    spawn_projects_fetch(api, tx);
}

pub fn spawn_threads_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        // Threads are now chats - need project_id to fetch
        // Return placeholder for now
        let placeholder = json!({
            "chats": []
        });
        let _ = tx.send(TuiMsg::Threads(Ok(crate::api::ApiResponse {
            request_id: None,
            elapsed_ms: 0,
            json: placeholder,
        })));
    });
}

pub fn spawn_memory_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        // Memory not yet implemented in Starbot_API
        let placeholder = json!({
            "items": []
        });
        let _ = tx.send(TuiMsg::Memory(Ok(crate::api::ApiResponse {
            request_id: None,
            elapsed_ms: 0,
            json: placeholder,
        })));
    });
}

pub fn spawn_memory_settings_fetch(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>) {
    tokio::spawn(async move {
        // Memory settings not yet implemented
        let placeholder = json!({
            "enabled": false
        });
        let _ = tx.send(TuiMsg::MemorySettings(Ok(crate::api::ApiResponse {
            request_id: None,
            elapsed_ms: 0,
            json: placeholder,
        })));
    });
}

pub fn spawn_memory_toggle(api: ApiClient, tx: mpsc::UnboundedSender<TuiMsg>, enabled: bool) {
    tokio::spawn(async move {
        // Memory toggle not yet implemented
        let placeholder = json!({
            "enabled": enabled
        });
        let _ = tx.send(TuiMsg::MemorySettings(Ok(crate::api::ApiResponse {
            request_id: None,
            elapsed_ms: 0,
            json: placeholder,
        })));
    });
}

pub fn spawn_tool_propose(
    api: ApiClient,
    tx: mpsc::UnboundedSender<TuiMsg>,
    tool_name: String,
    workspace_id: String,
    input: Value,
) {
    tokio::spawn(async move {
        // Tool proposal not yet implemented
        let placeholder = json!({
            "approved": false,
            "message": "Tool execution not yet implemented in Starbot_API"
        });
        let _ = tx.send(TuiMsg::Tool(tool_name, Ok(crate::api::ApiResponse {
            request_id: None,
            elapsed_ms: 0,
            json: placeholder,
        })));
    });
}

pub fn spawn_chat_request(
    api: ApiClient,
    tx: mpsc::UnboundedSender<TuiMsg>,
    provider: String,
    model: Option<String>,
    messages: Vec<ChatMsg>,
    workspace_id: Option<String>,
) {
    tokio::spawn(async move {
        // Non-streaming chat not yet fully wired - return placeholder
        // User should use streaming mode
        let placeholder = json!({
            "error": "Non-streaming mode not yet implemented. Use streaming mode."
        });
        let _ = tx.send(TuiMsg::Chat(Ok(crate::api::ApiResponse {
            request_id: None,
            elapsed_ms: 0,
            json: placeholder,
        })));
    });
}

key.rs

Path: starbot_tui/src/tui/handlers/key.rs

Size: 27827 bytes

// PHASE 3: TUI key handler extracted from tui.rs
// Contains handle_event and handle_key functions for keyboard input

use std::time::Instant;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::widgets::ListState;
use tokio::sync::mpsc;

use crate::api::ApiClient;
use crate::config::{profile_mut, save_config};
use crate::cute::CuteMode;
use crate::errors::CliError;
use crate::tui::types::{
    App, TuiMsg, ChatMsg, ChatRole, Mode, ChoiceAction, TextPromptState, ToolApprovalEntry,
};

use super::async_ops::{
    spawn_models_fetch, spawn_health_fetch, spawn_workspaces_fetch, spawn_threads_fetch,
    spawn_memory_fetch, spawn_memory_settings_fetch, spawn_memory_toggle,
    spawn_chat_request_stream, spawn_tool_propose,
};

// Import helper functions from parent tui module
use crate::commands::tui::thinking_status;

pub fn handle_event(
    api: &ApiClient,
    tx: &mpsc::UnboundedSender<TuiMsg>,
    app: &mut App,
    event: Event,
) -> Result<(), CliError> {
    match event {
        Event::Key(key) if key.kind == KeyEventKind::Press => {
            handle_key(api, tx, app, key)?;
        }
        _ => {}
    }
    Ok(())
}

pub fn handle_key(
    api: &ApiClient,
    tx: &mpsc::UnboundedSender<TuiMsg>,
    app: &mut App,
    key: crossterm::event::KeyEvent,
) -> Result<(), CliError> {
    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);

    match app.mode {
        Mode::Help => {
            if key.code == KeyCode::Esc
                || key.code == KeyCode::Enter
                || key.code == KeyCode::Char('q')
            {
                app.mode = Mode::Chat;
            }
            return Ok(());
        }
        Mode::ToolCard => {
            match key.code {
                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
                    if let Some(tool) = app.pending_tool.take() {
                        app.tool_approval_history.push(ToolApprovalEntry {
                            tool_name: tool.tool_name.clone(),
                            approved: true,
                        });
                        app.messages.push(ChatMsg {
                            role: ChatRole::System,
                            content: format!("Approved tool: {}", tool.tool_name),
                            sendable: false,
                        });
                        app.status = format!("Tool approved: {}", tool.tool_name);
                    }
                    app.mode = Mode::Chat;
                }
                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
                    if let Some(tool) = app.pending_tool.take() {
                        app.tool_approval_history.push(ToolApprovalEntry {
                            tool_name: tool.tool_name.clone(),
                            approved: false,
                        });
                        app.messages.push(ChatMsg {
                            role: ChatRole::System,
                            content: format!("Denied tool: {}", tool.tool_name),
                            sendable: false,
                        });
                        app.status = format!("Tool denied: {}", tool.tool_name);
                    }
                    app.mode = Mode::Chat;
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::ModelPicker => {
            match key.code {
                KeyCode::Esc => app.mode = Mode::Chat,
                KeyCode::Up => move_selection(&mut app.model_state, -1, app.model_options.len()),
                KeyCode::Down => move_selection(&mut app.model_state, 1, app.model_options.len()),
                KeyCode::PageUp => {
                    move_selection(&mut app.model_state, -5, app.model_options.len())
                }
                KeyCode::PageDown => {
                    move_selection(&mut app.model_state, 5, app.model_options.len())
                }
                KeyCode::Enter => {
                    if let Some(idx) = app.model_state.selected() {
                        if let Some(opt) = app.model_options.get(idx) {
                            app.selected_provider = opt.provider.clone();
                            app.selected_model = opt.model.clone();
                            // Avoid putting the full model id in the status line; the bottom panel
                            // already shows the selected model separately.
                            app.status = if app.selected_provider == "auto" {
                                "Selected auto".to_string()
                            } else {
                                format!("Selected {}", app.selected_provider)
                            };
                        }
                    }
                    app.mode = Mode::Chat;
                }
                KeyCode::Char('r') if ctrl => {
                    app.status = "Reloading models...".to_string();
                    app.bg_tasks = app.bg_tasks.saturating_add(1);
                    spawn_models_fetch(api.clone(), tx.clone());
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::WorkspacePicker => {
            match key.code {
                KeyCode::Esc => {
                    app.pending_workspace_retry = false;
                    app.mode = Mode::Chat;
                }
                KeyCode::Up => {
                    move_selection(&mut app.workspace_state, -1, app.workspace_options.len())
                }
                KeyCode::Down => {
                    move_selection(&mut app.workspace_state, 1, app.workspace_options.len())
                }
                KeyCode::PageUp => {
                    move_selection(&mut app.workspace_state, -5, app.workspace_options.len())
                }
                KeyCode::PageDown => {
                    move_selection(&mut app.workspace_state, 5, app.workspace_options.len())
                }
                KeyCode::Enter => {
                    if let Some(idx) = app.workspace_state.selected() {
                        let (id, name, archived) = match app.workspace_options.get(idx) {
                            Some(opt) => (opt.id.clone(), opt.name.clone(), opt.archived),
                            None => ("".to_string(), "".to_string(), false),
                        };
                        if id.trim().is_empty() {
                            app.status = "No workspace selected.".to_string();
                            return Ok(());
                        }
                        if archived {
                            app.status = format!("Workspace is archived: {name}");
                            return Ok(());
                        }
                        apply_workspace_selection(app, &id, Some(&name));
                        app.status = format!("Workspace: {name}");
                    }
                    app.mode = Mode::Chat;

                    if app.pending_workspace_retry {
                        app.pending_workspace_retry = false;
                        retry_last_chat(api, tx, app);
                    }
                }
                KeyCode::Char('r') if ctrl => {
                    if !app.token_present {
                        app.status = "Missing token. Run `starbott auth login` first.".to_string();
                        return Ok(());
                    }
                    app.status = "Reloading workspaces...".to_string();
                    app.bg_tasks = app.bg_tasks.saturating_add(1);
                    spawn_workspaces_fetch(api.clone(), tx.clone());
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::ThreadPicker => {
            match key.code {
                KeyCode::Esc => app.mode = Mode::Chat,
                KeyCode::Up => {
                    move_selection(&mut app.thread_state, -1, app.thread_options.len())
                }
                KeyCode::Down => {
                    move_selection(&mut app.thread_state, 1, app.thread_options.len())
                }
                KeyCode::PageUp => {
                    move_selection(&mut app.thread_state, -5, app.thread_options.len())
                }
                KeyCode::PageDown => {
                    move_selection(&mut app.thread_state, 5, app.thread_options.len())
                }
                KeyCode::Enter => {
                    if let Some(idx) = app.thread_state.selected() {
                        if let Some(thread) = app.thread_options.get(idx) {
                            app.active_thread_id = Some(thread.id.clone());
                            app.active_thread_title = Some(thread.title.clone());
                            app.status = format!("Switched to thread: {}", thread.title);
                            // Clear current messages when switching threads
                            app.messages.clear();
                        }
                    }
                    app.mode = Mode::Chat;
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::MemoryPanel => {
            match key.code {
                KeyCode::Esc => app.mode = Mode::Chat,
                KeyCode::Up => {
                    move_selection(&mut app.memory_state, -1, app.memory_items.len())
                }
                KeyCode::Down => {
                    move_selection(&mut app.memory_state, 1, app.memory_items.len())
                }
                KeyCode::PageUp => {
                    move_selection(&mut app.memory_state, -5, app.memory_items.len())
                }
                KeyCode::PageDown => {
                    move_selection(&mut app.memory_state, 5, app.memory_items.len())
                }
                KeyCode::Char('m') if ctrl => {
                    // Toggle memory
                    app.memory_enabled = !app.memory_enabled;
                    app.status = format!("Toggling memory {}...", if app.memory_enabled { "ON" } else { "OFF" });
                    app.bg_tasks = app.bg_tasks.saturating_add(1);
                    spawn_memory_toggle(api.clone(), tx.clone(), app.memory_enabled);
                }
                KeyCode::Char('r') if ctrl => {
                    // Refresh memory list
                    app.status = "Refreshing memory...".to_string();
                    app.bg_tasks = app.bg_tasks.saturating_add(2);
                    spawn_memory_fetch(api.clone(), tx.clone());
                    spawn_memory_settings_fetch(api.clone(), tx.clone());
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::ChoiceModal => {
            match key.code {
                KeyCode::Esc => {
                    app.choice_prompt = None;
                    app.mode = Mode::Chat;
                }
                KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => {
                    let len = app
                        .choice_prompt
                        .as_ref()
                        .map(|p| p.options.len())
                        .unwrap_or(0);
                    move_selection_wrap(&mut app.choice_state, 1, len);
                }
                KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') => {
                    let len = app
                        .choice_prompt
                        .as_ref()
                        .map(|p| p.options.len())
                        .unwrap_or(0);
                    move_selection_wrap(&mut app.choice_state, -1, len);
                }
                KeyCode::PageUp => {
                    let len = app
                        .choice_prompt
                        .as_ref()
                        .map(|p| p.options.len())
                        .unwrap_or(0);
                    move_selection(&mut app.choice_state, -5, len);
                }
                KeyCode::PageDown => {
                    let len = app
                        .choice_prompt
                        .as_ref()
                        .map(|p| p.options.len())
                        .unwrap_or(0);
                    move_selection(&mut app.choice_state, 5, len);
                }
                KeyCode::Enter => {
                    let Some(prompt) = app.choice_prompt.clone() else {
                        app.mode = Mode::Chat;
                        return Ok(());
                    };
                    let Some(idx) = app.choice_state.selected() else {
                        return Ok(());
                    };
                    let Some(opt) = prompt.options.get(idx).cloned() else {
                        return Ok(());
                    };

                    // Close modal before executing.
                    app.choice_prompt = None;
                    app.mode = Mode::Chat;

                    match opt.action {
                        ChoiceAction::SetWorkspace { workspace_id } => {
                            apply_workspace_selection(app, &workspace_id, Some(&opt.label));
                            app.status = format!("Workspace: {}", opt.label);

                            if app.pending_workspace_retry {
                                app.pending_workspace_retry = false;
                                retry_last_chat(api, tx, app);
                            }
                        }
                        ChoiceAction::Tool { tool_name, input } => {
                            let ws = match app.selected_workspace_id.clone() {
                                Some(v) if !v.trim().is_empty() => v,
                                _ => {
                                    app.messages.push(ChatMsg {
                                        role: ChatRole::System,
                                        content: "No workspace selected. Press F3 to pick one."
                                            .to_string(),
                                        sendable: false,
                                    });
                                    app.status = "Workspace required.".to_string();
                                    return Ok(());
                                }
                            };

                            app.messages.push(ChatMsg {
                                role: ChatRole::System,
                                content: format!("Running tool: {tool_name}"),
                                sendable: false,
                            });
                            app.waiting = true;
                            app.status = format!("Running tool: {tool_name}...");
                            app.bg_tasks = app.bg_tasks.saturating_add(1);
                            spawn_tool_propose(
                                api.clone(),
                                tx.clone(),
                                tool_name.clone(),
                                ws,
                                input,
                            );
                        }
                        ChoiceAction::Input { prompt } => {
                            app.text_prompt = Some(TextPromptState {
                                prompt,
                                input: Vec::new(),
                                cursor: 0,
                            });
                            app.mode = Mode::TextPromptModal;
                        }
                        ChoiceAction::SendMessage { text } => {
                            send_chat_text(api, tx, app, text);
                        }
                    }
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::TextPromptModal => {
            let Some(mut st) = app.text_prompt.clone() else {
                app.mode = Mode::Chat;
                return Ok(());
            };

            match key.code {
                KeyCode::Esc => {
                    app.text_prompt = None;
                    app.mode = Mode::Chat;
                }
                KeyCode::Enter => {
                    let value = st.input.iter().collect::<String>();
                    let trimmed = value.trim().to_string();
                    if !trimmed.is_empty() {
                        let msg = format!("{}\n{}", st.prompt.trim(), trimmed);
                        app.text_prompt = None;
                        app.mode = Mode::Chat;
                        send_chat_text(api, tx, app, msg);
                    }
                }
                KeyCode::Backspace => {
                    if st.cursor > 0 && st.cursor <= st.input.len() {
                        st.cursor -= 1;
                        st.input.remove(st.cursor);
                    }
                    app.text_prompt = Some(st);
                }
                KeyCode::Left => {
                    st.cursor = st.cursor.saturating_sub(1);
                    app.text_prompt = Some(st);
                }
                KeyCode::Right => {
                    st.cursor = (st.cursor + 1).min(st.input.len());
                    app.text_prompt = Some(st);
                }
                KeyCode::Home => {
                    st.cursor = 0;
                    app.text_prompt = Some(st);
                }
                KeyCode::End => {
                    st.cursor = st.input.len();
                    app.text_prompt = Some(st);
                }
                KeyCode::Char(ch) => {
                    if ctrl {
                        return Ok(());
                    }
                    if st.cursor > st.input.len() {
                        st.cursor = st.input.len();
                    }
                    st.input.insert(st.cursor, ch);
                    st.cursor += 1;
                    app.text_prompt = Some(st);
                }
                _ => {}
            }
            return Ok(());
        }
        Mode::Chat => {}
    }

    // Mode::Chat key handling
    match key.code {
        KeyCode::Esc => app.should_quit = true,
        KeyCode::Char('c') if ctrl => app.should_quit = true,
        KeyCode::F(1) => app.mode = Mode::Help,
        KeyCode::F(2) => app.mode = Mode::ModelPicker,
        KeyCode::F(3) => {
            if !app.token_present {
                app.messages.push(ChatMsg {
                    role: ChatRole::System,
                    content: "Missing token. Run `starbott auth login` to select a workspace."
                        .to_string(),
                    sendable: false,
                });
                app.status = "Missing token.".to_string();
                return Ok(());
            }
            if app.workspace_options.is_empty() {
                app.status = "Loading workspaces...".to_string();
                app.bg_tasks = app.bg_tasks.saturating_add(1);
                spawn_workspaces_fetch(api.clone(), tx.clone());
            }
            app.mode = Mode::WorkspacePicker;
        }
        KeyCode::F(4) => {
            if !app.token_present {
                app.messages.push(ChatMsg {
                    role: ChatRole::System,
                    content: "Missing token. Run `starbott auth login` to access threads.".to_string(),
                    sendable: false,
                });
                app.status = "Missing token.".to_string();
                return Ok(());
            }
            if app.thread_options.is_empty() {
                app.status = "Loading threads...".to_string();
                app.bg_tasks = app.bg_tasks.saturating_add(1);
                spawn_threads_fetch(api.clone(), tx.clone());
            }
            app.mode = Mode::ThreadPicker;
        }
        KeyCode::F(5) => {
            if !app.token_present {
                app.messages.push(ChatMsg {
                    role: ChatRole::System,
                    content: "Missing token. Run `starbott auth login` to access memory.".to_string(),
                    sendable: false,
                });
                app.status = "Missing token.".to_string();
                return Ok(());
            }
            // Load memory items and settings on first open
            if app.memory_items.is_empty() {
                app.status = "Loading memory...".to_string();
                app.bg_tasks = app.bg_tasks.saturating_add(2);
                spawn_memory_fetch(api.clone(), tx.clone());
                spawn_memory_settings_fetch(api.clone(), tx.clone());
            }
            app.mode = Mode::MemoryPanel;
        }
        KeyCode::Char('m') if ctrl => app.mode = Mode::ModelPicker,
        KeyCode::Char('d') if ctrl => app.show_debug = !app.show_debug,
        KeyCode::Char('r') if ctrl => {
            app.status = "Reloading...".to_string();
            app.bg_tasks = app.bg_tasks.saturating_add(2);
            spawn_models_fetch(api.clone(), tx.clone());
            spawn_health_fetch(api.clone(), tx.clone());
            if app.token_present {
                app.bg_tasks = app.bg_tasks.saturating_add(1);
                spawn_workspaces_fetch(api.clone(), tx.clone());
            }
        }
        KeyCode::PageUp => app.scroll_from_bottom = app.scroll_from_bottom.saturating_add(5),
        KeyCode::PageDown => app.scroll_from_bottom = app.scroll_from_bottom.saturating_sub(5),
        KeyCode::Enter => {
            if app.waiting {
                return Ok(());
            }
            let prompt = app.input.iter().collect::<String>();
            let trimmed = prompt.trim().to_string();
            if trimmed.is_empty() {
                return Ok(());
            }

            app.messages.push(ChatMsg {
                role: ChatRole::User,
                content: trimmed,
                sendable: true,
            });
            if app.cute != CuteMode::Off {
                // UI-only typing bubble: animated via spinner_step and not sent to the API.
                app.messages.push(ChatMsg {
                    role: ChatRole::Assistant,
                    content: "…".to_string(),
                    sendable: false,
                });
            }
            app.input.clear();
            app.cursor = 0;
            app.waiting = true;
            app.spinner_step = 0;
            app.spinner_last = Instant::now();
            app.status = thinking_status(app.cute, &mut app.rng, &mut app.last_phrase).to_string();

            let provider = app.selected_provider.clone();
            let model = app.selected_model.clone();
            let messages = app.messages.clone();
            let workspace_id = app.selected_workspace_id.clone();
            spawn_chat_request_stream(api.clone(), tx.clone(), provider, model, messages, workspace_id);
        }
        KeyCode::Backspace => {
            if app.cursor > 0 && app.cursor <= app.input.len() {
                app.cursor -= 1;
                app.input.remove(app.cursor);
            }
        }
        KeyCode::Left => {
            app.cursor = app.cursor.saturating_sub(1);
        }
        KeyCode::Right => {
            app.cursor = (app.cursor + 1).min(app.input.len());
        }
        KeyCode::Home => app.cursor = 0,
        KeyCode::End => app.cursor = app.input.len(),
        KeyCode::Char(ch) => {
            if ctrl {
                return Ok(());
            }
            if app.cursor > app.input.len() {
                app.cursor = app.input.len();
            }
            app.input.insert(app.cursor, ch);
            app.cursor += 1;
        }
        _ => {}
    }

    Ok(())
}

// ============================================================================
// Helper functions for key handling
// ============================================================================

fn move_selection(state: &mut ListState, delta: isize, len: usize) {
    if len == 0 {
        state.select(None);
        return;
    }
    let cur = state.selected().unwrap_or(0) as isize;
    let mut next = cur + delta;
    if next < 0 {
        next = 0;
    }
    if next as usize >= len {
        next = (len - 1) as isize;
    }
    state.select(Some(next as usize));
}

fn move_selection_wrap(state: &mut ListState, delta: isize, len: usize) {
    if len == 0 {
        state.select(None);
        return;
    }
    let cur = state.selected().unwrap_or(0) as isize;
    let mut next = cur + delta;
    if next < 0 {
        next = (len - 1) as isize;
    }
    if next as usize >= len {
        next = 0;
    }
    state.select(Some(next as usize));
}

fn apply_workspace_selection(app: &mut App, workspace_id: &str, name_hint: Option<&str>) {
    let ws = workspace_id.trim();
    if ws.is_empty() {
        return;
    }

    app.selected_workspace_id = Some(ws.to_string());
    app.selected_workspace_name = name_hint
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string());

    if let Some(idx) = app.workspace_options.iter().position(|w| w.id == ws) {
        app.workspace_state.select(Some(idx));
        if app.selected_workspace_name.is_none() {
            app.selected_workspace_name = Some(app.workspace_options[idx].name.clone());
        }
    }

    if let Some(p) = profile_mut(&mut app.config, &app.profile) {
        p.workspace_id = Some(ws.to_string());
        if let Err(e) = save_config(&app.config) {
            app.messages.push(ChatMsg {
                role: ChatRole::System,
                content: format!("Failed saving workspace selection: {e}"),
                sendable: false,
            });
        }
    } else {
        app.messages.push(ChatMsg {
            role: ChatRole::System,
            content: "Failed saving workspace selection: profile not found.".to_string(),
            sendable: false,
        });
    }
}

fn retry_last_chat(api: &ApiClient, tx: &mpsc::UnboundedSender<TuiMsg>, app: &mut App) {
    if app.waiting {
        return;
    }
    if !app
        .messages
        .iter()
        .any(|m| m.sendable && matches!(m.role, ChatRole::User))
    {
        return;
    }

    if app.cute != CuteMode::Off {
        app.messages.push(ChatMsg {
            role: ChatRole::Assistant,
            content: "…".to_string(),
            sendable: false,
        });
    }
    app.waiting = true;
    app.spinner_step = 0;
    app.spinner_last = Instant::now();
    app.status = thinking_status(app.cute, &mut app.rng, &mut app.last_phrase).to_string();

    let provider = app.selected_provider.clone();
    let model = app.selected_model.clone();
    let messages = app.messages.clone();
    let workspace_id = app.selected_workspace_id.clone();
    spawn_chat_request_stream(api.clone(), tx.clone(), provider, model, messages, workspace_id);
}

fn send_chat_text(api: &ApiClient, tx: &mpsc::UnboundedSender<TuiMsg>, app: &mut App, text: String) {
    if app.waiting {
        return;
    }
    let trimmed = text.trim().to_string();
    if trimmed.is_empty() {
        return;
    }

    app.messages.push(ChatMsg {
        role: ChatRole::User,
        content: trimmed,
        sendable: true,
    });
    if app.cute != CuteMode::Off {
        app.messages.push(ChatMsg {
            role: ChatRole::Assistant,
            content: "…".to_string(),
            sendable: false,
        });
    }
    app.input.clear();
    app.cursor = 0;
    app.waiting = true;
    app.spinner_step = 0;
    app.spinner_last = Instant::now();
    app.status = thinking_status(app.cute, &mut app.rng, &mut app.last_phrase).to_string();

    let provider = app.selected_provider.clone();
    let model = app.selected_model.clone();
    let messages = app.messages.clone();
    let workspace_id = app.selected_workspace_id.clone();
    spawn_chat_request_stream(api.clone(), tx.clone(), provider, model, messages, workspace_id);
}

chat.rs

Path: starbot_tui/src/commands/chat.rs

Size: 4200 bytes

use std::io::{self, Read};

use clap::Args;
use serde_json::{Value, json};

use crate::app::Runtime;
use crate::errors::CliError;
use crate::parse::response::{extract_reply, extract_provider_model, extract_usage_line};

#[derive(Debug, Args)]
pub struct ChatArgs {
    /// Prompt text
    pub prompt: Option<String>,
    /// Model or provider selector. Examples: "azure:gpt-5.2-chat" or "auto"
    #[arg(short = 'm', long = "model")]
    pub model: Option<String>,
    /// Optional conversation id
    #[arg(short = 'c', long = "conversation")]
    pub conversation: Option<String>,
    /// Read prompt from stdin
    #[arg(long)]
    pub stdin: bool,
    /// Request streaming response (falls back to non-streaming if unsupported)
    #[arg(long)]
    pub stream: bool,
    /// Optional max output tokens passthrough
    #[arg(long = "max-tokens")]
    pub max_tokens: Option<u32>,
}

pub async fn handle(runtime: &Runtime, args: ChatArgs) -> Result<(), CliError> {
    let prompt = resolve_prompt(&args)?;
    let api = runtime.api_client()?;

    if args.stream {
        runtime.output.print_verbose(
            "Requested --stream. Using standard response (CLI streaming output is not implemented yet).",
        );
    }

    let mut body = json!({
        "messages": [
            { "role": "user", "content": prompt }
        ],
        "client": "cli",
        "provider": "auto"
    });

    if let Some(conversation_id) = &args.conversation {
        body["conversationId"] = json!(conversation_id);
    }
    if let Some(max_tokens) = args.max_tokens {
        body["max_tokens"] = json!(max_tokens);
    }

    if let Some(model_or_provider) = &args.model {
        apply_model_selector(&mut body, model_or_provider);
    }

    let res = api.post_json("/v1/inference/chat", Some(body), true).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let reply = extract_reply(&res.json).unwrap_or_else(|| "(No text response)".to_string());
    runtime.output.print_human(&reply);

    if runtime.output.verbose {
        let (provider, model) = extract_provider_model(&res.json);
        let usage = extract_usage_line(&res.json);
        runtime.output.print_stderr(&format!(
            "provider={} model={} {}",
            provider.unwrap_or_else(|| "-".to_string()),
            model.unwrap_or_else(|| "-".to_string()),
            usage
        ));
    }

    Ok(())
}

fn resolve_prompt(args: &ChatArgs) -> Result<String, CliError> {
    if args.stdin {
        let mut input = String::new();
        io::stdin()
            .read_to_string(&mut input)
            .map_err(|e| CliError::Generic(format!("Failed reading stdin: {e}")))?;
        let trimmed = input.trim().to_string();
        if trimmed.is_empty() {
            return Err(CliError::Usage(
                "No prompt provided via stdin. Pipe text or pass a prompt argument.".to_string(),
            ));
        }
        return Ok(trimmed);
    }

    match &args.prompt {
        Some(value) if !value.trim().is_empty() => Ok(value.trim().to_string()),
        _ => Err(CliError::Usage(
            "Missing prompt. Use `starbott chat \"...\"` or pass `--stdin`.".to_string(),
        )),
    }
}

fn apply_model_selector(body: &mut Value, selector: &str) {
    let trimmed = selector.trim();
    if trimmed.is_empty() {
        return;
    }

    if let Some((provider, model)) = trimmed.split_once(':') {
        if !provider.trim().is_empty() {
            body["provider"] = json!(provider.trim());
        }
        if !model.trim().is_empty() {
            body["model"] = json!(model.trim());
        }
        return;
    }

    let lower = trimmed.to_ascii_lowercase();
    if matches!(
        lower.as_str(),
        "auto" | "kimi" | "gemini" | "vertex" | "cloudflare" | "azure" | "openai"
    ) {
        body["provider"] = json!(lower);
    } else {
        body["model"] = json!(trimmed);
    }
}

// PHASE 3: Functions moved to crate::parse::response module
// (Removed 60 lines of duplicate code)

tools.rs

Path: starbot_tui/src/commands/tools.rs

Size: 9675 bytes

use std::io::{self, Read};
use std::path::PathBuf;

use clap::Subcommand;
use serde_json::{Value, json};

use crate::app::Runtime;
use crate::errors::CliError;

#[derive(Debug, Subcommand)]
pub enum ToolsCommand {
    /// Propose a tool run (safe tools execute immediately; confirm tools require approval + commit).
    Propose {
        /// Workspace id
        #[arg(long)]
        workspace_id: String,
        /// Tool name (example: file.search, file.read, file.patch, web.search)
        #[arg(long)]
        tool_name: String,
        /// Tool input as JSON string
        #[arg(long)]
        input: Option<String>,
        /// Read tool input JSON from a file
        #[arg(long)]
        input_file: Option<PathBuf>,
        /// Read tool input JSON from stdin
        #[arg(long)]
        stdin: bool,
        /// Auto-approve confirm tools (skips prompt)
        #[arg(long)]
        yes: bool,
        /// Reason to include when denying a proposal
        #[arg(long)]
        deny_reason: Option<String>,
    },
    /// Commit an approved proposal.
    Commit {
        #[arg(long)]
        proposal_id: String,
    },
    /// Deny a pending proposal.
    Deny {
        #[arg(long)]
        proposal_id: String,
        #[arg(long)]
        reason: Option<String>,
    },
    /// List your tool runs.
    Runs {
        #[arg(long)]
        workspace_id: Option<String>,
        #[arg(long)]
        tool_name: Option<String>,
        #[arg(long)]
        limit: Option<u32>,
    },
}

pub async fn handle(runtime: &Runtime, command: ToolsCommand) -> Result<(), CliError> {
    match command {
        ToolsCommand::Propose {
            workspace_id,
            tool_name,
            input,
            input_file,
            stdin,
            yes,
            deny_reason,
        } => propose(runtime, workspace_id, tool_name, input, input_file, stdin, yes, deny_reason).await,
        ToolsCommand::Commit { proposal_id } => commit(runtime, proposal_id).await,
        ToolsCommand::Deny { proposal_id, reason } => deny(runtime, proposal_id, reason).await,
        ToolsCommand::Runs {
            workspace_id,
            tool_name,
            limit,
        } => list_runs(runtime, workspace_id, tool_name, limit).await,
    }
}

async fn propose(
    runtime: &Runtime,
    workspace_id: String,
    tool_name: String,
    input: Option<String>,
    input_file: Option<PathBuf>,
    stdin: bool,
    yes: bool,
    deny_reason: Option<String>,
) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    let tool_name = tool_name.trim().to_string();
    if tool_name.is_empty() {
        return Err(CliError::Usage("--tool-name must be non-empty.".to_string()));
    }

    let input_json = read_input_json(input, input_file, stdin)?;

    let body = json!({
        "workspaceId": workspace_id.trim(),
        "toolName": tool_name,
        "input": input_json,
    });

    let res = api.post_json("/v1/tools/propose", Some(body), true).await?;
    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let requires = res
        .json
        .get("requiresConfirmation")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    if !requires {
        let run_id = res.json.get("runId").and_then(|v| v.as_str()).unwrap_or("-");
        runtime
            .output
            .print_human(&format!("tool completed: runId={run_id}"));

        if let Some(result) = res.json.get("result") {
            runtime.output.print_human(&format_json(result)?);
        }
        return Ok(());
    }

    let proposal_id = res
        .json
        .get("proposalId")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    if proposal_id.is_empty() {
        return Err(CliError::Server("Missing proposalId in response".to_string()));
    }

    let expires_at = res.json.get("expiresAt").and_then(|v| v.as_str()).unwrap_or("-");
    runtime
        .output
        .print_human(&format!("tool requires approval: proposalId={proposal_id} expiresAt={expires_at}"));

    if let Some(preview) = res.json.get("preview") {
        runtime.output.print_human(&format_json(preview)?);
    }

    let approve = if yes {
        true
    } else {
        prompt_approve()?
    };

    if approve {
        let commit_res = api
            .post_json("/v1/tools/commit", Some(json!({ "proposalId": proposal_id })), true)
            .await?;
        runtime.output.print_human("committed.");
        runtime.output.print_human(&format_json(&commit_res.json)?);
        return Ok(());
    }

    let deny_body = if let Some(reason) = deny_reason {
        json!({ "proposalId": proposal_id, "reason": reason })
    } else {
        json!({ "proposalId": proposal_id })
    };
    let deny_res = api.post_json("/v1/tools/deny", Some(deny_body), true).await?;
    runtime.output.print_human("denied.");
    runtime.output.print_human(&format_json(&deny_res.json)?);
    Ok(())
}

async fn commit(runtime: &Runtime, proposal_id: String) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let pid = proposal_id.trim();
    if pid.is_empty() {
        return Err(CliError::Usage("--proposal-id is required.".to_string()));
    }

    let res = api
        .post_json("/v1/tools/commit", Some(json!({ "proposalId": pid })), true)
        .await?;

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    runtime.output.print_human(&format_json(&res.json)?);
    Ok(())
}

async fn deny(runtime: &Runtime, proposal_id: String, reason: Option<String>) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let pid = proposal_id.trim();
    if pid.is_empty() {
        return Err(CliError::Usage("--proposal-id is required.".to_string()));
    }

    let body = if let Some(r) = reason.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
        json!({ "proposalId": pid, "reason": r })
    } else {
        json!({ "proposalId": pid })
    };

    let res = api.post_json("/v1/tools/deny", Some(body), true).await?;
    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }
    runtime.output.print_human(&format_json(&res.json)?);
    Ok(())
}

async fn list_runs(
    runtime: &Runtime,
    workspace_id: Option<String>,
    tool_name: Option<String>,
    limit: Option<u32>,
) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    let mut query: Vec<(String, String)> = Vec::new();
    if let Some(ws) = workspace_id.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
        query.push(("workspaceId".to_string(), ws));
    }
    if let Some(t) = tool_name.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
        query.push(("tool".to_string(), t));
    }
    if let Some(l) = limit {
        query.push(("limit".to_string(), l.to_string()));
    }

    let query_ref = if query.is_empty() { None } else { Some(query.as_slice()) };
    let res = api.get_json("/v1/tools/runs", query_ref, true).await?;

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let runs = res.json.get("runs").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    if runs.is_empty() {
        runtime.output.print_human("No tool runs.");
        return Ok(());
    }

    for r in runs {
        let tool = r.get("toolName").and_then(|v| v.as_str()).unwrap_or("-");
        let status = r.get("status").and_then(|v| v.as_str()).unwrap_or("-");
        let created = r.get("createdAt").and_then(|v| v.as_str()).unwrap_or("-");
        let id = r.get("id").and_then(|v| v.as_str()).unwrap_or("-");
        runtime.output.print_human(&format!("- {created} {tool} {status} ({id})"));
    }

    Ok(())
}

fn read_input_json(input: Option<String>, input_file: Option<PathBuf>, stdin: bool) -> Result<Value, CliError> {
    let sources = [
        input.as_ref().map(|_| "input"),
        input_file.as_ref().map(|_| "input_file"),
        if stdin { Some("stdin") } else { None },
    ]
    .into_iter()
    .flatten()
    .collect::<Vec<_>>();

    if sources.len() > 1 {
        return Err(CliError::Usage(
            "Pass only one of --input, --input-file, or --stdin.".to_string(),
        ));
    }

    let text = if let Some(raw) = input {
        raw
    } else if let Some(path) = input_file {
        std::fs::read_to_string(&path).map_err(|e| {
            CliError::Usage(format!("Failed to read {}: {e}", path.display()))
        })?
    } else if stdin {
        let mut buf = String::new();
        io::stdin()
            .read_to_string(&mut buf)
            .map_err(|e| CliError::Generic(format!("Failed reading stdin: {e}")))?;
        buf
    } else {
        "{}".to_string()
    };

    let trimmed = text.trim();
    if trimmed.is_empty() {
        return Ok(json!({}));
    }

    let parsed = serde_json::from_str::<Value>(trimmed).map_err(|e| {
        CliError::Usage(format!("Invalid JSON input: {e}"))
    })?;

    if !parsed.is_object() {
        return Err(CliError::Usage("Tool input must be a JSON object.".to_string()));
    }

    Ok(parsed)
}

fn prompt_approve() -> Result<bool, CliError> {
    eprint!("Approve? [y/N] ");
    let _ = io::Write::flush(&mut io::stderr());
    let mut line = String::new();
    io::stdin()
        .read_line(&mut line)
        .map_err(|e| CliError::Generic(format!("Failed reading input: {e}")))?;
    let s = line.trim().to_ascii_lowercase();
    Ok(s == "y" || s == "yes")
}

fn format_json(value: &Value) -> Result<String, CliError> {
    Ok(serde_json::to_string_pretty(value)?)
}

mod.rs

Path: starbot_tui/src/commands/mod.rs

Size: 156 bytes

pub mod auth;
pub mod billing;
pub mod chat;
pub mod config;
pub mod health;
pub mod tools;
pub mod tui;
pub mod usage;
pub mod whoami;
pub mod workspaces;

whoami.rs

Path: starbot_tui/src/commands/whoami.rs

Size: 1175 bytes

use crate::app::Runtime;
use crate::errors::CliError;

pub async fn handle(runtime: &Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.get_json("/v1/auth/me", None, true).await?;

    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let id = res.json.get("id").and_then(|v| v.as_str()).unwrap_or("-");
    let email = res
        .json
        .get("email")
        .and_then(|v| v.as_str())
        .unwrap_or("-");

    runtime.output.print_human(&format!("id: {id}"));
    runtime.output.print_human(&format!("email: {email}"));

    let plan_status = res
        .json
        .get("planStatus")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    let period_end = res
        .json
        .get("currentPeriodEnd")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    runtime.output.print_human(&format!("plan_status: {plan_status}"));
    runtime.output.print_human(&format!("period_end: {period_end}"));

    Ok(())
}

auth.rs

Path: starbot_tui/src/commands/auth.rs

Size: 6801 bytes

use clap::Subcommand;
use serde_json::json;

use crate::app::Runtime;
use crate::config::{ensure_profile, profile_mut, save_config};
use crate::errors::CliError;

#[derive(Debug, Subcommand)]
pub enum AuthCommand {
    /// Authenticate via device code flow (QR or manual)
    Login {
        /// Paste a token directly instead of using device code flow
        #[arg(long)]
        token: Option<String>,
    },
    /// Remove bearer token from active profile
    Logout,
}

pub async fn handle(runtime: &mut Runtime, command: AuthCommand) -> Result<(), CliError> {
    match command {
        AuthCommand::Login { token } => login(runtime, token).await,
        AuthCommand::Logout => logout(runtime).await,
    }
}

async fn login(runtime: &mut Runtime, token: Option<String>) -> Result<(), CliError> {
    // If --token provided, use direct token save (legacy / CI mode)
    if let Some(t) = token {
        let t = t.trim().to_string();
        if t.is_empty() {
            return Err(CliError::Usage("Token cannot be empty.".to_string()));
        }
        return save_token(runtime, t, None);
    }

    if is_ci() {
        return Err(CliError::Usage(
            "CI mode detected. Pass `--token` explicitly.".to_string(),
        ));
    }

    // Device code flow
    device_code_flow(runtime).await
}

async fn device_code_flow(runtime: &mut Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    // 1. POST /v1/auth/device/start
    let hostname = hostname::get()
        .ok()
        .and_then(|h| h.into_string().ok())
        .unwrap_or_else(|| "unknown".to_string());

    let start_body = json!({
        "client_name": "starbott",
        "client_version": env!("CARGO_PKG_VERSION"),
        "device_meta": {
            "hostname": hostname,
            "os": std::env::consts::OS,
            "arch": std::env::consts::ARCH,
        }
    });

    let res = api
        .post_json_with_body("/v1/auth/device/start", &start_body, false)
        .await?;

    let device_code = res
        .json
        .get("deviceCode")
        .and_then(|v| v.as_str())
        .ok_or_else(|| CliError::Server("Missing deviceCode in response".to_string()))?
        .to_string();

    let user_code = res
        .json
        .get("userCode")
        .and_then(|v| v.as_str())
        .ok_or_else(|| CliError::Server("Missing userCode in response".to_string()))?
        .to_string();

    let verification_url = res
        .json
        .get("verificationUrl")
        .and_then(|v| v.as_str())
        .ok_or_else(|| CliError::Server("Missing verificationUrl in response".to_string()))?
        .to_string();

    let interval = res
        .json
        .get("interval")
        .and_then(|v| v.as_u64())
        .unwrap_or(5);

    // 2. Render QR code
    let auth_url = format!("{}?code={}", verification_url, user_code);
    render_qr(&auth_url);

    runtime.output.print_human("");
    runtime
        .output
        .print_human(&format!("  Scan the QR code or visit: {}", verification_url));
    runtime.output.print_human(&format!("  Code: {}", user_code));
    runtime.output.print_human("");
    runtime
        .output
        .print_human("  Waiting for authorization...");

    // 3. Poll loop
    let poll_body = json!({ "deviceCode": device_code });
    let max_attempts = 180 / interval; // 3 minutes max

    for _ in 0..max_attempts {
        tokio::time::sleep(std::time::Duration::from_secs(interval)).await;

        let poll_res = match api
            .post_json_with_body("/v1/auth/device/poll", &poll_body, false)
            .await
        {
            Ok(r) => r,
            Err(_) => continue, // network blip, retry
        };

        let status = poll_res
            .json
            .get("status")
            .and_then(|v| v.as_str())
            .unwrap_or("pending");

        match status {
            "authorized" => {
                let access_token = poll_res
                    .json
                    .get("accessToken")
                    .and_then(|v| v.as_str())
                    .unwrap_or("")
                    .to_string();
                let refresh_token = poll_res
                    .json
                    .get("refreshToken")
                    .and_then(|v| v.as_str())
                    .map(|s| s.to_string());

                save_token(runtime, access_token, refresh_token)?;

                if runtime.output.json {
                    runtime.output.print_json(&json!({ "ok": true }))?;
                } else {
                    runtime.output.print_human("  Logged in successfully.");
                }
                return Ok(());
            }
            "expired" => {
                return Err(CliError::Auth(
                    "Device code expired. Please try again.".to_string(),
                ));
            }
            _ => {
                // pending — keep polling
            }
        }
    }

    Err(CliError::Auth(
        "Authorization timed out. Please try again.".to_string(),
    ))
}

fn render_qr(url: &str) {
    use qrcode::QrCode;

    let code = match QrCode::new(url.as_bytes()) {
        Ok(c) => c,
        Err(_) => {
            // If QR generation fails, just skip it
            return;
        }
    };

    let string = code
        .render::<char>()
        .quiet_zone(true)
        .module_dimensions(2, 1)
        .build();

    println!();
    for line in string.lines() {
        println!("  {}", line);
    }
}

fn save_token(
    runtime: &mut Runtime,
    access_token: String,
    refresh_token: Option<String>,
) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    ensure_profile(&mut runtime.config, &profile_name);
    let profile = profile_mut(&mut runtime.config, &profile_name)
        .ok_or_else(|| CliError::Generic("Failed to load active profile.".to_string()))?;
    profile.token = Some(access_token);
    if let Some(rt) = refresh_token {
        profile.refresh_token = Some(rt);
    }
    save_config(&runtime.config)?;
    Ok(())
}

async fn logout(runtime: &mut Runtime) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    ensure_profile(&mut runtime.config, &profile_name);
    let profile = profile_mut(&mut runtime.config, &profile_name)
        .ok_or_else(|| CliError::Generic("Failed to load active profile.".to_string()))?;
    profile.token = None;
    profile.refresh_token = None;
    save_config(&runtime.config)?;

    if runtime.output.json {
        runtime.output.print_json(&json!({ "ok": true }))?;
    } else {
        runtime.output.print_human("Logged out.");
    }
    Ok(())
}

fn is_ci() -> bool {
    std::env::var("CI")
        .ok()
        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
        .unwrap_or(false)
}

workspaces.rs

Path: starbot_tui/src/commands/workspaces.rs

Size: 6320 bytes

use std::path::PathBuf;

use clap::Subcommand;
use serde_json::{Value, json};

use crate::app::Runtime;
use crate::errors::CliError;

#[derive(Debug, Subcommand)]
pub enum WorkspaceCommand {
    /// Create a workspace rooted at an absolute directory path.
    Create {
        /// Workspace name (defaults to the directory name)
        #[arg(long)]
        name: Option<String>,
        /// Absolute path to workspace root (defaults to current directory)
        #[arg(long)]
        root: Option<PathBuf>,
    },
    /// List workspaces visible to the current user.
    List,
    /// Set workspace permissions (owner-only).
    Permissions {
        /// Workspace id
        workspace_id: String,
        /// Target user id (defaults to self)
        #[arg(long)]
        user_id: Option<String>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_read_files: Option<bool>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_write_files: Option<bool>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_read_images: Option<bool>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_write_images: Option<bool>,
        #[arg(long, value_parser = clap::value_parser!(bool))]
        can_web_search: Option<bool>,
    },
}

pub async fn handle(runtime: &Runtime, command: WorkspaceCommand) -> Result<(), CliError> {
    match command {
        WorkspaceCommand::Create { name, root } => create(runtime, name, root).await,
        WorkspaceCommand::List => list(runtime).await,
        WorkspaceCommand::Permissions {
            workspace_id,
            user_id,
            can_read_files,
            can_write_files,
            can_read_images,
            can_write_images,
            can_web_search,
        } => {
            set_permissions(
                runtime,
                workspace_id,
                user_id,
                can_read_files,
                can_write_files,
                can_read_images,
                can_write_images,
                can_web_search,
            )
            .await
        }
    }
}

async fn create(runtime: &Runtime, name: Option<String>, root: Option<PathBuf>) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    let root = root.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
    let resolved = std::fs::canonicalize(&root).map_err(|e| {
        CliError::Usage(format!("Invalid workspace root {}: {e}", root.display()))
    })?;

    let default_name = resolved
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("workspace")
        .to_string();

    let name = name
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .unwrap_or(default_name);

    let body = json!({
        "name": name,
        "rootPath": resolved.display().to_string(),
    });

    let res = api.post_json("/v1/workspaces", Some(body), true).await?;
    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let ws = res.json.get("workspace").cloned().unwrap_or_else(|| json!({}));
    let id = ws.get("id").and_then(|v| v.as_str()).unwrap_or("-");
    let root_path = ws.get("rootPath").and_then(|v| v.as_str()).unwrap_or("-");
    runtime.output.print_human(&format!("workspace created: id={id} rootPath={root_path}"));
    Ok(())
}

async fn list(runtime: &Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.get_json("/v1/workspaces", None, true).await?;

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let items = res
        .json
        .get("workspaces")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();

    if items.is_empty() {
        runtime.output.print_human("No workspaces.");
        return Ok(());
    }

    for w in items {
        let id = w.get("id").and_then(|v| v.as_str()).unwrap_or("-");
        let name = w.get("name").and_then(|v| v.as_str()).unwrap_or("-");
        let root = w.get("rootPath").and_then(|v| v.as_str()).unwrap_or("-");
        runtime.output.print_human(&format!("- {name}  ({id})  {root}"));
    }

    Ok(())
}

async fn set_permissions(
    runtime: &Runtime,
    workspace_id: String,
    user_id: Option<String>,
    can_read_files: Option<bool>,
    can_write_files: Option<bool>,
    can_read_images: Option<bool>,
    can_write_images: Option<bool>,
    can_web_search: Option<bool>,
) -> Result<(), CliError> {
    let api = runtime.api_client()?;

    let mut body = json!({});
    if let Some(uid) = user_id.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) {
        body["userId"] = json!(uid);
    }

    // Send snake_case keys (server also accepts camelCase).
    if let Some(v) = can_read_files { body["can_read_files"] = json!(v); }
    if let Some(v) = can_write_files { body["can_write_files"] = json!(v); }
    if let Some(v) = can_read_images { body["can_read_images"] = json!(v); }
    if let Some(v) = can_write_images { body["can_write_images"] = json!(v); }
    if let Some(v) = can_web_search { body["can_web_search"] = json!(v); }

    if body.as_object().map(|o| o.is_empty()).unwrap_or(true) {
        return Err(CliError::Usage(
            "No permission fields provided. Pass e.g. --can-write-files true".to_string(),
        ));
    }

    let path = format!("/v1/workspaces/{}/permissions", workspace_id.trim());
    let res = api.post_json(&path, Some(body), true).await?;

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let perm = res.json.get("permission").cloned().unwrap_or_else(|| json!({}));
    runtime.output.print_human(&format!("permissions updated: {}", summarize_perm(&perm)));
    Ok(())
}

fn summarize_perm(perm: &Value) -> String {
    let bool_field = |k: &str| perm.get(k).and_then(|v| v.as_bool()).unwrap_or(false);
    format!(
        "read_files={} write_files={} read_images={} write_images={} web_search={}",
        bool_field("can_read_files"),
        bool_field("can_write_files"),
        bool_field("can_read_images"),
        bool_field("can_write_images"),
        bool_field("can_web_search"),
    )
}

usage.rs

Path: starbot_tui/src/commands/usage.rs

Size: 1732 bytes

use clap::{Args, ValueEnum};

use crate::app::Runtime;
use crate::errors::CliError;

#[derive(Debug, Clone, ValueEnum)]
pub enum UsageGroup {
    Day,
    Model,
    Provider,
}

#[derive(Debug, Args)]
pub struct UsageArgs {
    #[arg(long)]
    pub since: Option<String>,
    #[arg(long)]
    pub until: Option<String>,
    #[arg(long, value_enum)]
    pub group: Option<UsageGroup>,
}

pub async fn handle(runtime: &Runtime, args: UsageArgs) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    // v1 API currently exposes the current monthly usage window only.
    // Ignore grouping filters for now (kept for backward compat CLI flags).
    let _ = args;

    let res = api.get_json("/v1/usage/current", None, true).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let total = res
        .json
        .get("totalTokens")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    let limit = res
        .json
        .get("tokenLimit")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);
    let start = res
        .json
        .get("periodStart")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    let end = res
        .json
        .get("periodEnd")
        .and_then(|v| v.as_str())
        .unwrap_or("-");

    runtime.output.print_human(&format!("total_tokens: {total}"));
    runtime.output.print_human(&format!("token_limit: {limit}"));
    runtime.output.print_human(&format!("period_start: {start}"));
    runtime.output.print_human(&format!("period_end: {end}"));
    Ok(())
}

config.rs

Path: starbot_tui/src/commands/config.rs

Size: 6852 bytes

use clap::{Subcommand, ValueEnum};
use serde_json::json;

use crate::app::Runtime;
use crate::config::{ensure_profile, profile_mut, profile_ref, save_config, validate_url};
use crate::errors::{CliError, redact_secret};

#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
    /// Initialize config file and profile
    Init {
        #[arg(long = "api-url")]
        api_url: Option<String>,
        #[arg(long)]
        token: Option<String>,
    },
    /// Read a config key from the active profile
    Get {
        key: ConfigKey,
        #[arg(long)]
        show_token: bool,
    },
    /// Set a config key on the active profile
    Set { key: ConfigKey, value: String },
    /// List all profiles
    Profiles,
    /// Switch active profile
    Use { profile: String },
}

#[derive(Debug, Clone, ValueEnum)]
pub enum ConfigKey {
    #[value(name = "apiUrl")]
    ApiUrl,
    #[value(name = "token")]
    Token,
}

pub async fn handle(runtime: &mut Runtime, command: ConfigCommand) -> Result<(), CliError> {
    match command {
        ConfigCommand::Init { api_url, token } => init(runtime, api_url, token).await,
        ConfigCommand::Get { key, show_token } => get(runtime, key, show_token).await,
        ConfigCommand::Set { key, value } => set(runtime, key, value).await,
        ConfigCommand::Profiles => profiles(runtime).await,
        ConfigCommand::Use { profile } => use_profile(runtime, profile).await,
    }
}

async fn init(
    runtime: &mut Runtime,
    api_url: Option<String>,
    token: Option<String>,
) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    ensure_profile(&mut runtime.config, &profile_name);
    if let Some(profile) = profile_mut(&mut runtime.config, &profile_name) {
        if let Some(url) = api_url {
            validate_url(&url)?;
            profile.api_url = url;
        }

        if let Some(value) = token {
            profile.token = Some(value);
        } else if !is_ci() && !runtime.output.json && !runtime.output.quiet {
            let maybe_token = rpassword::prompt_password("Token (optional, Enter to skip): ")
                .map_err(|e| CliError::Generic(format!("Failed reading token: {e}")))?;
            if !maybe_token.trim().is_empty() {
                profile.token = Some(maybe_token.trim().to_string());
            }
        }
    }

    runtime.config.profile = profile_name;
    let path = save_config(&runtime.config)?;
    runtime.config_path = path.clone();

    if runtime.output.json {
        runtime
            .output
            .print_json(&json!({ "ok": true, "path": path }))?;
    } else {
        runtime
            .output
            .print_human(&format!("Config initialized: {}", path.display()));
    }
    Ok(())
}

async fn get(runtime: &mut Runtime, key: ConfigKey, show_token: bool) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    let profile = profile_ref(&runtime.config, &profile_name).ok_or_else(|| {
        CliError::Usage(format!(
            "Profile '{profile_name}' not found. Run `starbott config init` first."
        ))
    })?;

    match key {
        ConfigKey::ApiUrl => {
            if runtime.output.json {
                runtime.output.print_json(&json!({
                    "key": "apiUrl",
                    "value": profile.api_url
                }))?;
            } else {
                runtime.output.print_human(&profile.api_url);
            }
        }
        ConfigKey::Token => {
            let resolved = runtime.resolved_token();
            let display = resolved.as_deref().map(|t| {
                if show_token {
                    t.to_string()
                } else {
                    redact_secret(t)
                }
            });

            if runtime.output.json {
                runtime.output.print_json(&json!({
                    "key": "token",
                    "value": display
                }))?;
            } else if let Some(v) = display {
                runtime.output.print_human(&v);
            } else {
                runtime.output.print_human("(not set)");
            }
        }
    }

    Ok(())
}

async fn set(runtime: &mut Runtime, key: ConfigKey, value: String) -> Result<(), CliError> {
    let profile_name = runtime.active_profile();
    ensure_profile(&mut runtime.config, &profile_name);
    let profile = profile_mut(&mut runtime.config, &profile_name).ok_or_else(|| {
        CliError::Generic(format!(
            "Failed to resolve profile '{profile_name}' while setting config."
        ))
    })?;

    match key {
        ConfigKey::ApiUrl => {
            validate_url(&value)?;
            profile.api_url = value;
        }
        ConfigKey::Token => {
            profile.token = Some(value);
        }
    }

    let path = save_config(&runtime.config)?;
    runtime.config_path = path;

    if runtime.output.json {
        runtime.output.print_json(&json!({ "ok": true }))?;
    } else {
        runtime.output.print_human("Config updated.");
    }

    Ok(())
}

async fn profiles(runtime: &mut Runtime) -> Result<(), CliError> {
    let active = runtime.active_profile();
    let mut names: Vec<String> = runtime.config.profiles.keys().cloned().collect();
    names.sort();

    if runtime.output.json {
        let payload = names
            .iter()
            .map(|name| {
                let profile = runtime.config.profiles.get(name);
                json!({
                    "name": name,
                    "active": name == &active,
                    "apiUrl": profile.map(|p| p.api_url.clone()).unwrap_or_default(),
                    "hasToken": profile
                        .and_then(|p| p.token.as_ref())
                        .map(|t| !t.is_empty())
                        .unwrap_or(false)
                })
            })
            .collect::<Vec<_>>();
        runtime.output.print_json(&json!({ "profiles": payload }))?;
        return Ok(());
    }

    for name in names {
        let marker = if name == active { "*" } else { " " };
        runtime.output.print_human(&format!("{marker} {name}"));
    }
    Ok(())
}

async fn use_profile(runtime: &mut Runtime, profile_name: String) -> Result<(), CliError> {
    ensure_profile(&mut runtime.config, &profile_name);
    runtime.config.profile = profile_name.clone();
    let path = save_config(&runtime.config)?;
    runtime.config_path = path;

    if runtime.output.json {
        runtime
            .output
            .print_json(&json!({ "ok": true, "profile": profile_name }))?;
    } else {
        runtime
            .output
            .print_human(&format!("Active profile: {profile_name}"));
    }

    Ok(())
}

fn is_ci() -> bool {
    std::env::var("CI")
        .ok()
        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
        .unwrap_or(false)
}

health.rs

Path: starbot_tui/src/commands/health.rs

Size: 1322 bytes

use crate::app::Runtime;
use crate::errors::CliError;

pub async fn handle(runtime: &Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.get_json("/health", None, false).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let ok = res
        .json
        .get("ok")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let version = res
        .json
        .get("version")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    let inference = res
        .json
        .get("inference")
        .and_then(|v| v.as_str())
        .unwrap_or("-");

    runtime.output.print_human(&format!("ok: {ok}"));
    runtime.output.print_human(&format!("version: {version}"));
    runtime
        .output
        .print_human(&format!("inference: {inference}"));

    if let Some(providers) = res.json.get("providers").and_then(|v| v.as_object()) {
        for (name, status) in providers {
            runtime.output.print_human(&format!(
                "provider.{name}: {}",
                status.as_str().unwrap_or("-")
            ));
        }
    }

    Ok(())
}

billing.rs

Path: starbot_tui/src/commands/billing.rs

Size: 2295 bytes

use clap::{Args, Subcommand};
use serde_json::json;

use crate::app::Runtime;
use crate::errors::CliError;

#[derive(Debug, Subcommand)]
pub enum BillingCommand {
    /// Show subscription and plan status
    Status,
    /// Create a billing portal session URL
    Portal(PortalArgs),
}

#[derive(Debug, Args)]
pub struct PortalArgs {
    #[arg(long)]
    pub open: bool,
}

pub async fn handle(runtime: &Runtime, command: BillingCommand) -> Result<(), CliError> {
    match command {
        BillingCommand::Status => status(runtime).await,
        BillingCommand::Portal(args) => portal(runtime, args).await,
    }
}

async fn status(runtime: &Runtime) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.get_json("/v1/auth/me", None, true).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    if runtime.output.json {
        runtime.output.print_json(&res.json)?;
        return Ok(());
    }

    let plan_status = res
        .json
        .get("planStatus")
        .and_then(|v| v.as_str())
        .unwrap_or("-");
    let renews = res
        .json
        .get("currentPeriodEnd")
        .and_then(|v| v.as_str())
        .unwrap_or("-");

    runtime.output.print_human(&format!("plan_status: {plan_status}"));
    runtime.output.print_human(&format!("period_end: {renews}"));
    Ok(())
}

async fn portal(runtime: &Runtime, args: PortalArgs) -> Result<(), CliError> {
    let api = runtime.api_client()?;
    let res = api.post_json("/v1/billing/portal-session", None, true).await?;
    runtime.output.print_verbose(&format!(
        "request_id={:?} elapsed_ms={}",
        res.request_id, res.elapsed_ms
    ));

    let url = res
        .json
        .get("url")
        .and_then(|v| v.as_str())
        .ok_or_else(|| {
            CliError::Server("Billing portal URL was not returned by the server.".to_string())
        })?;

    if runtime.output.json {
        runtime.output.print_json(&json!({ "url": url }))?;
    } else {
        runtime.output.print_human(url);
    }

    if args.open {
        open::that(url).map_err(|e| {
            CliError::Generic(format!("Failed to open browser for portal URL: {e}"))
        })?;
    }

    Ok(())
}

tui.rs

Path: starbot_tui/src/commands/tui.rs

Size: 89796 bytes

use std::io;
use std::time::{Duration, Instant};

use clap::Args;
use crossterm::cursor::Show;
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::block::{Position, Title};
use ratatui::widgets::{
    Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap,
};
use ratatui::{Frame, Terminal};
use serde_json::{Value, json};
use tokio::sync::mpsc;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

use crate::api::{ApiClient, ApiResponse};
use crate::app::Runtime;
use crate::config::{CliConfig, profile_mut, profile_ref, save_config};
use crate::cute::{CuteMode, load_cute_mode};
use crate::errors::CliError;

// PHASE 3: Import handler modules and response parsing
use crate::parse::response::{extract_reply, extract_provider_model, extract_usage_line};
use crate::tui::types::*;
use crate::tui::handlers::{handle_event, handle_tui_msg};
use crate::tui::handlers::async_ops::{
    spawn_models_fetch, spawn_health_fetch, spawn_workspaces_fetch,
    spawn_chat_request_stream, spawn_tool_propose,
};

#[derive(Debug, Args)]
pub struct TuiArgs {
    /// Model selector. Examples: "vertex:gemini-3-flash-preview" or "auto"
    #[arg(short = 'm', long = "model")]
    pub model: Option<String>,
}
struct TerminalGuard;

impl TerminalGuard {
    fn enter() -> Result<Self, CliError> {
        enable_raw_mode()
            .map_err(|e| CliError::Generic(format!("Failed to enable raw mode: {e}")))?;
        let mut stdout = io::stdout();
        execute!(stdout, EnterAlternateScreen)
            .map_err(|e| CliError::Generic(format!("Failed to enter alternate screen: {e}")))?;
        Ok(Self)
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let mut stdout = io::stdout();
        let _ = execute!(stdout, Show, LeaveAlternateScreen);
    }
}

pub async fn handle(runtime: &Runtime, args: TuiArgs) -> Result<(), CliError> {
    if runtime.output.json {
        return Err(CliError::Usage(
            "`--json` is not supported for `starbott tui`.".to_string(),
        ));
    }

    let api = runtime.api_client()?;
    let api_url = runtime.resolved_api_url()?;
    let profile = runtime.active_profile();
    let config = runtime.config.clone();
    let token_present = runtime.resolved_token().is_some();
    let cute = load_cute_mode();
    let selected_workspace_id = profile_ref(&config, &profile).and_then(|p| p.workspace_id.clone());

    let (initial_provider, initial_model) = parse_model_selector(args.model.as_deref());

    let guard = TerminalGuard::enter()?;

    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)
        .map_err(|e| CliError::Generic(format!("Failed to init terminal: {e}")))?;
    terminal
        .clear()
        .map_err(|e| CliError::Generic(format!("Failed to clear terminal: {e}")))?;
    terminal
        .hide_cursor()
        .map_err(|e| CliError::Generic(format!("Failed to hide cursor: {e}")))?;

    let mut app = App {
        mode: Mode::Chat,
        should_quit: false,
        api_url,
        config,
        profile,
        token_present,
        cute,
        rng: seed_rng(),
        last_phrase: None,
        success_count: 0,
        lane: None,
        hints: ProviderHints {
            vertex_ok: None,
            azure_present: false,
            cf_present: false,
        },
        spinner_step: 0,
        spinner_last: Instant::now(),
        bg_tasks: 0,
        messages: vec![ChatMsg {
            role: ChatRole::System,
            content: "Starbot TUI. Enter to send. F2 models. F3 workspace. F1 help. Esc quit.".to_string(),
            sendable: false,
        }],
        input: Vec::new(),
        cursor: 0,
        waiting: false,
        status: startup_status(cute).to_string(),
        last_request_id: None,
        last_elapsed_ms: None,
        last_provider: None,
        last_model: None,
        last_usage: None,
        activity_lines: Vec::new(),
        current_file: None,
        auto_edits: false,
        working_dir: std::env::current_dir()
            .map(|p| p.display().to_string())
            .unwrap_or_else(|_| ".".to_string()),
        model_options: default_model_options(),
        model_state: ListState::default(),
        selected_provider: initial_provider,
        selected_model: initial_model,
        workspace_options: Vec::new(),
        workspace_state: ListState::default(),
        selected_workspace_id,
        selected_workspace_name: None,
        pending_workspace_retry: false,
        thread_options: Vec::new(),
        thread_state: ListState::default(),
        active_thread_id: None,
        active_thread_title: None,
        memory_items: Vec::new(),
        memory_state: ListState::default(),
        memory_settings: MemorySettings::default(),
        memory_enabled: true,
        choice_prompt: None,
        choice_state: ListState::default(),
        text_prompt: None,
        scroll_from_bottom: 0,
        show_debug: false,
        pending_tool: None,
        tool_approval_history: Vec::new(),
    };

    app.model_state.select(Some(0));
    app.workspace_state.select(Some(0));

    let (tx, mut rx) = mpsc::unbounded_channel::<TuiMsg>();
    app.bg_tasks = app.bg_tasks.saturating_add(2);
    spawn_models_fetch(api.clone(), tx.clone());
    spawn_health_fetch(api.clone(), tx.clone());
    if app.token_present {
        app.bg_tasks = app.bg_tasks.saturating_add(1);
        spawn_workspaces_fetch(api.clone(), tx.clone());
    }

    loop {
        update_spinner(&mut app);
        terminal
            .draw(|f| ui(f, &mut app))
            .map_err(|e| CliError::Generic(format!("Failed to draw: {e}")))?;

        if app.should_quit {
            break;
        }

        while let Ok(msg) = rx.try_recv() {
            handle_tui_msg(&api, &tx, &mut app, msg);
        }

        // Don't redraw at full speed when idle; keep a calmer cadence.
        let poll_ms = if app.waiting || app.bg_tasks > 0 || app.mode != Mode::Chat {
            50
        } else {
            120
        };
        if crossterm::event::poll(Duration::from_millis(poll_ms))
            .map_err(|e| CliError::Generic(format!("Event poll failed: {e}")))?
        {
            let event = crossterm::event::read()
                .map_err(|e| CliError::Generic(format!("Event read failed: {e}")))?;
            if let Err(err) = handle_event(&api, &tx, &mut app, event) {
                app.messages.push(ChatMsg {
                    role: ChatRole::System,
                    content: format!("Error: {err}"),
                    sendable: false,
                });
                app.status = "Error".to_string();
            }
        }
    }

    terminal
        .show_cursor()
        .map_err(|e| CliError::Generic(format!("Failed to restore cursor: {e}")))?;
    drop(guard);
    Ok(())
}

fn default_model_options() -> Vec<ModelOption> {
    vec![
        ModelOption {
            provider: "auto".to_string(),
            model: None,
            label: "Auto".to_string(),
        },
        ModelOption {
            provider: "kimi".to_string(),
            model: None,
            label: "Kimi K2".to_string(),
        },
        ModelOption {
            provider: "vertex".to_string(),
            model: Some("gemini-3-flash-preview".to_string()),
            label: "Gemini 3 Flash Preview".to_string(),
        },
    ]
}

fn parse_model_selector(selector: Option<&str>) -> (String, Option<String>) {
    let Some(raw) = selector else {
        return ("auto".to_string(), None);
    };
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return ("auto".to_string(), None);
    }

    if let Some((provider, model)) = trimmed.split_once(':') {
        let p = provider.trim().to_ascii_lowercase();
        let m = model.trim().to_string();
        return (p, if m.is_empty() { None } else { Some(m) });
    }

    let lower = trimmed.to_ascii_lowercase();
    if matches!(
        lower.as_str(),
        "auto" | "kimi" | "gemini" | "vertex" | "cloudflare" | "azure" | "openai"
    ) {
        return (lower, None);
    }

    // Convenient default for "gemini-*" model ids.
    ("vertex".to_string(), Some(trimmed.to_string()))
}

pub fn parse_model_options(payload: &Value) -> Option<Vec<ModelOption>> {
    let providers = payload.get("providers")?.as_array()?;
    let mut options = Vec::new();

    for p in providers {
        let label = p
            .get("label")
            .and_then(|v| v.as_str())
            .or_else(|| p.get("id").and_then(|v| v.as_str()))
            .unwrap_or("unknown")
            .to_string();

        let provider = p
            .get("provider")
            .and_then(|v| v.as_str())
            .or_else(|| p.get("id").and_then(|v| v.as_str()))
            .unwrap_or("auto")
            .to_string();

        let model = p
            .get("model")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());

        // Normalize provider for "auto"/"kimi" entries where provider is absent and "id" is used.
        let provider = if provider.contains(':') {
            provider.split(':').next().unwrap_or("auto").to_string()
        } else {
            provider
        };

        options.push(ModelOption {
            provider,
            model,
            label,
        });
    }

    Some(options)
}

pub fn parse_workspace_options(payload: &Value) -> Option<Vec<WorkspaceOption>> {
    let workspaces = payload.get("workspaces")?.as_array()?;
    let mut options = Vec::new();

    for w in workspaces {
        let id = w.get("id").and_then(|v| v.as_str()).map(str::trim).unwrap_or("");
        if id.is_empty() {
            continue;
        }
        let name = w
            .get("name")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .unwrap_or("workspace")
            .to_string();
        let root_path = w
            .get("rootPath")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());
        let archived = w.get("archived").and_then(|v| v.as_bool()).unwrap_or(false);
        let last_used_at = w
            .get("lastUsedAt")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());

        options.push(WorkspaceOption {
            id: id.to_string(),
            name,
            root_path,
            archived,
            last_used_at,
        });
    }

    Some(options)
}

pub fn parse_thread_options(payload: &Value) -> Option<Vec<ThreadOption>> {
    let threads = payload.get("threads")?.as_array()?;
    let mut options = Vec::new();

    for t in threads {
        let id = t.get("id").and_then(|v| v.as_str()).map(str::trim).unwrap_or("");
        if id.is_empty() {
            continue;
        }
        let title = t
            .get("title")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .unwrap_or("Untitled")
            .to_string();
        let mode = t
            .get("mode")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());
        let last_message_at = t
            .get("lastMessageAt")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string());
        let is_pinned = t.get("isPinned").and_then(|v| v.as_bool()).unwrap_or(false);
        let message_count = t.get("_count")
            .and_then(|v| v.get("messages"))
            .and_then(|v| v.as_u64())
            .unwrap_or(0) as usize;

        options.push(ThreadOption {
            id: id.to_string(),
            title,
            mode,
            last_message_at,
            is_pinned,
            message_count,
        });
    }

    Some(options)
}

pub fn parse_memory_items(payload: &Value) -> Option<Vec<MemoryItem>> {
    let items = payload.get("items")?.as_array()?;
    let mut options = Vec::new();

    for item in items {
        let id = item.get("id").and_then(|v| v.as_str()).map(str::trim).unwrap_or("");
        if id.is_empty() {
            continue;
        }

        let scope = item.get("scope").and_then(|v| v.as_str()).unwrap_or("global").to_string();
        let project_id = item.get("projectId").and_then(|v| v.as_str()).map(|s| s.to_string());
        let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("fact").to_string();
        let content = item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
        let tags = item.get("tags")
            .and_then(|v| v.as_array())
            .map(|arr| arr.iter().filter_map(|v| v.as_str()).map(|s| s.to_string()).collect())
            .unwrap_or_default();
        let salience = item.get("salience").and_then(|v| v.as_f64()).unwrap_or(0.5);
        let confidence = item.get("confidence").and_then(|v| v.as_f64()).unwrap_or(0.7);
        let source = item.get("source").and_then(|v| v.as_str()).unwrap_or("manual").to_string();
        let enabled = item.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
        let created_at = item.get("createdAt").and_then(|v| v.as_str()).unwrap_or("").to_string();
        let updated_at = item.get("updatedAt").and_then(|v| v.as_str()).unwrap_or("").to_string();

        options.push(MemoryItem {
            id: id.to_string(),
            scope,
            project_id,
            item_type,
            content,
            tags,
            salience,
            confidence,
            source,
            enabled,
            created_at,
            updated_at,
        });
    }

    Some(options)
}

pub fn parse_memory_settings(payload: &Value) -> Option<MemorySettings> {
    Some(MemorySettings {
        enabled: payload.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
        allow_auto_capture: payload.get("allowAutoCapture").and_then(|v| v.as_bool()).unwrap_or(true),
        max_context_tokens: payload.get("maxContextTokens").and_then(|v| v.as_u64()).unwrap_or(600) as usize,
        max_items_injected: payload.get("maxItemsInjected").and_then(|v| v.as_u64()).unwrap_or(12) as usize,
        include_global: payload.get("includeGlobal").and_then(|v| v.as_bool()).unwrap_or(true),
        include_project: payload.get("includeProject").and_then(|v| v.as_bool()).unwrap_or(true),
    })
}

pub fn parse_choice_prompt(payload: &Value) -> Option<ChoicePrompt> {
    let cp = payload
        .get("choicePrompt")
        .or_else(|| payload.get("choice_prompt"))?;
    let title = cp
        .get("title")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or("Choose an option")
        .to_string();
    let id = cp
        .get("id")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or("choice")
        .to_string();
    let hint = cp
        .get("hint")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .unwrap_or("")
        .to_string();
    let allow_custom = cp
        .get("allowCustom")
        .or_else(|| cp.get("allow_custom"))
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let custom_placeholder = cp
        .get("customPlaceholder")
        .or_else(|| cp.get("custom_placeholder"))
        .and_then(|v| v.as_str())
        .map(str::trim)
        .unwrap_or("")
        .to_string();

    let options_v = cp.get("options")?.as_array()?;
    let mut options: Vec<ChoiceOption> = Vec::new();

    for o in options_v {
        let oid = o.get("id").and_then(|v| v.as_str()).map(str::trim).unwrap_or("");
        let label = o
            .get("label")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .unwrap_or(oid)
            .to_string();
        if label.is_empty() {
            continue;
        }
        let desc = o
            .get("description")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .unwrap_or("")
            .to_string();

        let action = o.get("action")?;
        let typ = action.get("type").and_then(|v| v.as_str()).unwrap_or("");

        let parsed_action = match typ {
            "set_workspace" => {
                let ws = action
                    .get("workspaceId")
                    .or_else(|| action.get("workspace_id"))
                    .and_then(|v| v.as_str())
                    .map(str::trim)
                    .unwrap_or("");
                if ws.is_empty() {
                    continue;
                }
                ChoiceAction::SetWorkspace {
                    workspace_id: ws.to_string(),
                }
            }
            "tool" => {
                let tool = action
                    .get("toolName")
                    .or_else(|| action.get("tool_name"))
                    .or_else(|| action.get("tool"))
                    .and_then(|v| v.as_str())
                    .map(str::trim)
                    .unwrap_or("");
                if tool.is_empty() {
                    continue;
                }
                let input = action.get("input").cloned().unwrap_or_else(|| json!({}));
                ChoiceAction::Tool {
                    tool_name: tool.to_string(),
                    input,
                }
            }
            "input" => {
                let prompt = action
                    .get("prompt")
                    .and_then(|v| v.as_str())
                    .map(str::trim)
                    .unwrap_or("Input");
                ChoiceAction::Input {
                    prompt: prompt.to_string(),
                }
            }
            "send_message" => {
                let text = action
                    .get("text")
                    .and_then(|v| v.as_str())
                    .map(str::trim)
                    .unwrap_or("");
                if text.is_empty() {
                    continue;
                }
                ChoiceAction::SendMessage {
                    text: text.to_string(),
                }
            }
            _ => {
                continue;
            }
        };

        options.push(ChoiceOption {
            id: oid.to_string(),
            label,
            description: desc,
            action: parsed_action,
        });
    }

    if options.is_empty() {
        return None;
    }

    Some(ChoicePrompt {
        id,
        title,
        hint,
        allow_custom,
        custom_placeholder,
        options,
    })
}

pub fn find_selected_model_index(
    options: &[ModelOption],
    provider: &str,
    model: Option<&str>,
) -> Option<usize> {
    options.iter().position(|o| {
        o.provider == provider
            && match (o.model.as_deref(), model) {
                (None, None) => true,
                (Some(a), Some(b)) => a == b,
                _ => false,
            }
    })
}

fn ui(f: &mut Frame<'_>, app: &mut App) {
    let size = f.area();

    // Layout: file panel | main list | input | info panel
    // If a file is active, show it at top; otherwise give all space to the main list.
    let has_file = app.current_file.is_some();
    let file_height = if has_file { 6u16 } else { 0 };

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(file_height),  // file being edited
            Constraint::Min(1),              // main list (chat)
            Constraint::Length(3),           // input prompt
            Constraint::Length(4),           // info panel (dir/model/auto-edits/status)
        ])
        .split(size);

    // ── File panel (top) ──
    if has_file {
        render_file_panel(f, app, layout[0]);
    }

    // ── Main list (chat) ──
    let (chat_area, debug_area) = if app.show_debug {
        let cols = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Percentage(72), Constraint::Percentage(28)])
            .split(layout[1]); // main list row
        (cols[0], Some(cols[1]))
    } else {
        (layout[1], None)
    };

    let chat = render_chat(app, chat_area);
    f.render_widget(chat, chat_area);

    if let Some(area) = debug_area {
        let dbg = render_debug(app);
        f.render_widget(dbg, area);
    }

    // ── Input prompt (enclosed with heart prefix) ──
    let input = render_input(app);
    f.render_widget(input, layout[2]);

    if app.mode == Mode::Chat {
        let prompt_cols = input_prompt_len(app) as u16;
        let x = layout[2]
            .x
            .saturating_add(1)
            .saturating_add(prompt_cols)
            .saturating_add(app.cursor as u16);
        let y = layout[2].y.saturating_add(1);
        f.set_cursor_position((x.min(layout[2].x + layout[2].width - 2), y));
    }

    // ── Info panel (bottom, below the input) ──
    render_info_panel(f, app, layout[3]);

    // ── Popups ──
    match app.mode {
        Mode::ModelPicker => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_model_picker_popup(f, app, area);
        }
        Mode::WorkspacePicker => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_workspace_picker_popup(f, app, area);
        }
        Mode::ThreadPicker => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_thread_picker_popup(f, app, area);
        }
        Mode::MemoryPanel => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_memory_panel_popup(f, app, area);
        }
        Mode::ChoiceModal => {
            let area = centered_rect(80, 70, size);
            f.render_widget(Clear, area);
            render_choice_modal_popup(f, app, area);
        }
        Mode::TextPromptModal => {
            let area = centered_rect(70, 40, size);
            f.render_widget(Clear, area);
            render_text_prompt_popup(f, app, area);
        }
        Mode::Help => {
            let area = centered_rect(70, 60, size);
            f.render_widget(Clear, area);
            let popup = render_help(app);
            f.render_widget(popup, area);
        }
        Mode::ToolCard => {
            let area = centered_rect(70, 50, size);
            f.render_widget(Clear, area);
            let popup = render_tool_card(app);
            f.render_widget(popup, area);
        }
        Mode::Chat => {}
    }
}

fn render_file_panel(f: &mut Frame<'_>, app: &App, area: Rect) {
    let file_name = app.current_file.as_deref().unwrap_or("");
    let title = format!(" {} ", file_name);

    let mut lines = Vec::new();
    // Show last few activity lines as context for what's happening to this file
    let start = app.activity_lines.len().saturating_sub(4);
    for line in &app.activity_lines[start..] {
        lines.push(Line::from(vec![Span::styled(
            format!("  {line}"),
            Style::default().fg(c_muted()),
        )]));
    }
    if lines.is_empty() {
        lines.push(Line::from(vec![Span::styled(
            "  (no activity)",
            Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
        )]));
    }

    let widget = Paragraph::new(Text::from(lines))
        .block({
            let mut block = Block::default()
                .borders(Borders::ALL)
                .title(title);
            if app.cute != CuteMode::Off {
                block = block.border_style(Style::default().fg(c_sparkle()));
            }
            block
        })
        .wrap(Wrap { trim: false });
    f.render_widget(widget, area);
}

fn render_info_bar(f: &mut Frame<'_>, app: &App, area: Rect) {
    let auto_label = if app.auto_edits { "auto-edits: on" } else { "auto-edits: off" };

    let dir_display = if app.working_dir.len() > 30 {
        let truncated = &app.working_dir[app.working_dir.len().saturating_sub(28)..];
        format!("..{truncated}")
    } else {
        app.working_dir.clone()
    };

    let model_display = match (&app.last_provider, &app.last_model) {
        (Some(p), Some(m)) => format!("{p}:{m}"),
        (Some(p), None) => p.clone(),
        _ => "none".to_string(),
    };

    let spans = if app.cute == CuteMode::Off {
        vec![
            Span::raw(format!(
                " {auto_label}  |  dir: {dir_display}  |  model: {model_display}  |  {}", app.status
            )),
        ]
    } else {
        vec![
            Span::styled(" ♡ ", Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
            Span::styled(auto_label, Style::default().fg(c_muted())),
            Span::styled("  |  ", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
            Span::styled(format!("dir: {dir_display}"), Style::default().fg(c_muted())),
            Span::styled("  |  ", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
            Span::styled(format!("model: {model_display}"), Style::default().fg(c_sparkle())),
            Span::styled("  |  ", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
            Span::styled(app.status.clone(), Style::default().fg(c_muted())),
        ]
    };

    let bar = Paragraph::new(Line::from(spans))
        .style(Style::default().bg(Color::Rgb(30, 30, 40)).fg(Color::White));
    f.render_widget(bar, area);
}

fn render_info_panel(f: &mut Frame<'_>, app: &App, area: Rect) {
    let auto_label = if app.auto_edits { "auto-edits: on" } else { "auto-edits: off" };

    let ws_display = match (&app.selected_workspace_name, &app.selected_workspace_id) {
        (Some(name), _) if !name.trim().is_empty() => name.trim().to_string(),
        (_, Some(id)) if !id.trim().is_empty() => id.trim().to_string(),
        _ => "none".to_string(),
    };

    let last_model_display = match (&app.last_provider, &app.last_model) {
        (Some(p), Some(m)) => format!("{p}:{m}"),
        (Some(p), None) => p.clone(),
        _ => "none".to_string(),
    };

    let selected_display = format!(
        "{}{}",
        app.selected_provider,
        app.selected_model
            .as_deref()
            .map(|m| format!(":{m}"))
            .unwrap_or_default()
    );
    let show_last_used_model = app.selected_provider == "auto";

    let status_line = if app.cute == CuteMode::Off {
        Line::from(format!(" {}", app.status))
    } else {
        let mut spans: Vec<Span<'static>> = Vec::new();
        spans.push(Span::raw(" "));
        spans.push(Span::styled(
            "♡",
            Style::default().fg(c_heart()).add_modifier(Modifier::BOLD),
        ));
        spans.push(Span::raw("  "));
        spans.extend(lane_spans(app.lane));
        spans.push(Span::raw("  "));
        spans.push(Span::styled(app.status.clone(), Style::default().fg(c_muted())));
        Line::from(spans)
    };

    let (line2_spans, line3_spans) = if app.cute == CuteMode::Off {
        let line3 = if show_last_used_model {
            format!(" selected: {selected_display}  |  last: {last_model_display} ")
        } else {
            format!(" selected: {selected_display} ")
        };
        (
            vec![Span::raw(format!(" {auto_label}  |  workspace: {ws_display} "))],
            vec![Span::raw(line3)],
        )
    } else {
        let line2_spans = vec![
            Span::styled(auto_label, Style::default().fg(c_muted())),
            Span::styled(
                "  |  ",
                Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
            ),
            Span::styled("workspace: ", Style::default().fg(c_muted())),
            Span::styled(ws_display, Style::default().fg(c_sparkle())),
        ];

        let mut line3_spans = vec![
            Span::raw("   "),
            Span::styled("selected: ", Style::default().fg(c_muted())),
            Span::styled(selected_display, Style::default().fg(c_sparkle())),
        ];
        if show_last_used_model {
            line3_spans.push(Span::styled(
                "  |  ",
                Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
            ));
            line3_spans.push(Span::styled("last: ", Style::default().fg(c_muted())));
            line3_spans.push(Span::styled(last_model_display, Style::default().fg(c_muted())));
        }
        (line2_spans, line3_spans)
    };

    let req = app.last_request_id.as_deref().unwrap_or("-");
    let elapsed = app
        .last_elapsed_ms
        .map(|v| format!("{v}ms"))
        .unwrap_or_else(|| "-".to_string());
    let usage_right = app
        .last_usage
        .as_deref()
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
        .unwrap_or("");

    let total_width = area.width.max(1) as usize;

    // Right column: req/elapsed above usage, both aligned to the right edge.
    let req_elapsed = format!("req: {req} | elapsed: {elapsed}");
    let req_elapsed_right = if app.cute == CuteMode::Off {
        vec![Span::raw(req_elapsed)]
    } else {
        vec![Span::styled(req_elapsed, Style::default().fg(c_muted()))]
    };

    let usage_right = if usage_right.is_empty() {
        Vec::new()
    } else if app.cute == CuteMode::Off {
        vec![Span::raw(usage_right.to_string())]
    } else {
        vec![Span::styled(
            usage_right.to_string(),
            Style::default().fg(c_muted()),
        )]
    };

    let line2 = compose_lr_line(total_width, &line2_spans, &req_elapsed_right);
    let line3 = compose_lr_line(total_width, &line3_spans, &usage_right);

    // Shift everything down one row.
    let panel = Paragraph::new(Text::from(vec![Line::from(""), status_line, line2, line3]))
        .style(Style::default().bg(Color::Rgb(30, 30, 40)).fg(Color::White))
        .wrap(Wrap { trim: false });
    f.render_widget(panel, area);
}

fn render_header(f: &mut Frame<'_>, app: &App, area: Rect) {
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
        .split(area);

    let base = Style::default().fg(Color::Black).bg(Color::White);
    let left = Paragraph::new(header_left_line(app)).style(base);

    let sel = format!(
        "{}{}",
        app.selected_provider,
        app.selected_model
            .as_deref()
            .map(|m| format!(":{m}"))
            .unwrap_or_default()
    );
    let token = if app.token_present {
        "token=yes"
    } else {
        "token=no"
    };
    let mut right_spans = provider_health_spans(app.cute, app.hints);
    right_spans.push(Span::raw(format!("  api={}  profile={}  {}  {sel} ", app.api_url, app.profile, token)));
    let right = Paragraph::new(Line::from(right_spans))
        .style(base)
        .alignment(Alignment::Right);

    f.render_widget(left, cols[0]);
    f.render_widget(right, cols[1]);
}

fn render_chat(app: &App, area: Rect) -> Paragraph<'static> {
    let width = area.width.saturating_sub(2).max(1) as usize;
    let height = area.height.saturating_sub(2).max(1) as usize;

    let all_lines = build_chat_lines(
        &app.messages,
        width,
        app.cute,
        app.spinner_step,
        app.spinner_last,
    );
    let total = all_lines.len();
    let max_scroll = total.saturating_sub(height);
    let scroll = app.scroll_from_bottom.min(max_scroll);
    let top = max_scroll.saturating_sub(scroll);
    let end = (top + height).min(total);

    let visible = &all_lines[top..end];
    let text = Text::from(visible.to_vec());

    let base_style = if app.cute == CuteMode::Off {
        Style::default()
    } else {
        // Match the logo "negative space" and the bottom panels.
        Style::default()
            .bg(Color::Rgb(30, 30, 40))
            .fg(Color::White)
    };

    Paragraph::new(text)
        .block(
            {
                let left_title = "Chat (PgUp/PgDn scroll, Ctrl+D debug)";
                let title_width = area.width.saturating_sub(2).max(1) as usize;
                let left_w = left_title.width();
                let label = "dir: ";
                // Add a little spacing so the right title doesn't crowd the left title.
                let max_dir_w = title_width.saturating_sub(left_w + label.width() + 4);
                let dir_display = tail_truncate_to_width(&app.working_dir, max_dir_w);

                let mut block = Block::default()
                    .borders(Borders::ALL)
                    .border_type(BorderType::Rounded)
                    .title(left_title);

                // Put cwd on the top-right of the chat border.
                let dir_span = if app.cute == CuteMode::Off {
                    Span::raw(format!(" {label}{dir_display} "))
                } else {
                    Span::styled(
                        format!(" {label}{dir_display} "),
                        Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
                    )
                };
                block = block.title(
                    Title::from(Line::from(vec![dir_span]))
                        .alignment(Alignment::Right)
                        .position(Position::Top),
                );
                if app.cute != CuteMode::Off && (app.waiting || app.bg_tasks > 0) {
                    block = block.title(
                        Title::from(Line::from(border_blink_spans(
                            app.spinner_step,
                            area.width,
                        )))
                            .alignment(Alignment::Left)
                            .position(Position::Bottom),
                    );
                }
                if app.cute != CuteMode::Off {
                    block = block.border_style(Style::default().fg(c_sparkle()));
                }
                block
            },
        )
        .style(base_style)
        .wrap(Wrap { trim: false })
}

fn render_debug(app: &App) -> Paragraph<'static> {
    let mut lines = Vec::new();
    lines.push(Line::from(vec![Span::styled(
        "Debug",
        Style::default().add_modifier(Modifier::BOLD),
    )]));
    lines.push(Line::from(format!("waiting={}", app.waiting)));
    lines.push(Line::from(format!(
        "selected={}",
        format!(
            "{}{}",
            app.selected_provider,
            app.selected_model
                .as_deref()
                .map(|m| format!(":{m}"))
                .unwrap_or_default()
        )
    )));
    lines.push(Line::from(format!(
        "req_id={}",
        app.last_request_id.as_deref().unwrap_or("-")
    )));
    lines.push(Line::from(format!(
        "elapsed_ms={}",
        app.last_elapsed_ms
            .map(|v| v.to_string())
            .unwrap_or_else(|| "-".to_string())
    )));
    lines.push(Line::from(""));
    lines.push(Line::from("Keys"));
    lines.push(Line::from("F2: models"));
    lines.push(Line::from("F3: workspaces"));
    lines.push(Line::from("F1: help"));
    lines.push(Line::from("Esc: quit"));
    lines.push(Line::from("Ctrl+R: reload"));

    Paragraph::new(Text::from(lines))
        .block(Block::default().borders(Borders::ALL).title("Info"))
        .wrap(Wrap { trim: false })
}

fn render_input(app: &App) -> Paragraph<'static> {
    let input = app.input.iter().collect::<String>();

    let text = if app.cute == CuteMode::Off {
        Text::from(input)
    } else {
        let prefix = input_prompt_prefix(app.cute);
        Text::from(Line::from(vec![
            Span::styled(prefix, Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
            Span::raw(input),
        ]))
    };

    Paragraph::new(text)
        .block({
            let mut block = Block::default()
                .borders(Borders::ALL)
                .border_type(BorderType::Rounded);
            if app.cute != CuteMode::Off {
                let c = if app.waiting { c_heart() } else { c_muted() };
                block = block.border_style(Style::default().fg(c));
            }
            block
        })
        .wrap(Wrap { trim: false })
}

fn render_model_picker_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title("Models (Up/Down, Enter select, Esc cancel)");
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1)])
        .split(inner);

    let list = render_model_picker(app);
    f.render_stateful_widget(list, rows[0], &mut app.model_state);

    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[1]);
}

fn render_workspace_picker_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title("Workspaces (Up/Down, Enter select, Esc cancel, Ctrl+R refresh)");
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1)])
        .split(inner);

    let list = render_workspace_picker(app);
    f.render_stateful_widget(list, rows[0], &mut app.workspace_state);

    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[1]);
}

fn render_choice_modal_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let title = app
        .choice_prompt
        .as_ref()
        .map(|p| p.title.as_str())
        .unwrap_or("Choose");
    let hint = app
        .choice_prompt
        .as_ref()
        .map(|p| p.hint.as_str())
        .unwrap_or("Tab/↑↓ select • Enter choose • Esc cancel");

    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title(title);
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1), Constraint::Length(1)])
        .split(inner);

    let list = render_choice_list(app);
    f.render_stateful_widget(list, rows[0], &mut app.choice_state);

    let hint_line = Paragraph::new(Line::from(vec![Span::styled(
        hint.to_string(),
        Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
    )]))
    .alignment(Alignment::Center);
    f.render_widget(hint_line, rows[1]);

    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[2]);
}

fn render_text_prompt_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let prompt = app
        .text_prompt
        .as_ref()
        .map(|p| p.prompt.as_str())
        .unwrap_or("Input");
    let input = app
        .text_prompt
        .as_ref()
        .map(|p| p.input.iter().collect::<String>())
        .unwrap_or_default();
    let cursor = app.text_prompt.as_ref().map(|p| p.cursor).unwrap_or(0);

    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title("Input (Enter submit, Esc cancel)");
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let prefix = "> ";
    let lines = vec![
        Line::from(vec![Span::styled(
            prompt.to_string(),
            Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
        )]),
        Line::from(""),
        Line::from(vec![
            Span::styled(prefix, Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)),
            Span::raw(input.clone()),
        ]),
    ];

    let widget = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
    f.render_widget(widget, inner);

    // Cursor on input line.
    let x = inner
        .x
        .saturating_add(prefix.width() as u16)
        .saturating_add(cursor as u16);
    let y = inner.y.saturating_add(2);
    if x < inner.x + inner.width && y < inner.y + inner.height {
        f.set_cursor_position((x, y));
    }
}

fn render_stargazing_bar(app: &App) -> Paragraph<'static> {
    if app.cute == CuteMode::Off {
        return Paragraph::new(Line::from(""));
    }

    // From SPECS/LOADINGSPECS.md.
    const SYMBOLS: &[&str] = &["✦", "✧", "✶", "✷", "✶", "✧"];
    let sym = SYMBOLS[(app.spinner_step as usize) % SYMBOLS.len()];
    let sym_style = match sym {
        "✶" | "✷" => Style::default().fg(c_heart()).add_modifier(Modifier::BOLD),
        _ => Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD),
    };

    let line = Line::from(vec![
        Span::styled(
            "stargazing… ",
            Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
        ),
        Span::styled(sym.to_string(), sym_style),
    ]);

    Paragraph::new(line).alignment(Alignment::Center)
}

fn render_model_picker(app: &App) -> List<'static> {
    let items = if app.model_options.is_empty() {
        vec![ListItem::new("No models loaded.")]
    } else {
        app.model_options
            .iter()
            .map(|m| {
                let id = format!(
                    "{}{}",
                    m.provider,
                    m.model
                        .as_deref()
                        .map(|v| format!(":{v}"))
                        .unwrap_or_default()
                );
                ListItem::new(Line::from(format!("{:<24} {}", id, m.label)))
            })
            .collect::<Vec<_>>()
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn render_workspace_picker(app: &App) -> List<'static> {
    let items = if app.workspace_options.is_empty() {
        vec![ListItem::new("No workspaces loaded.")]
    } else {
        app.workspace_options
            .iter()
            .map(|w| {
                let archived = if w.archived { " (archived)" } else { "" };
                let mut line = format!("{}{}  ({})", w.name, archived, w.id);
                if let Some(ref root) = w.root_path {
                    let root_disp = tail_truncate_to_width(root, 46);
                    line.push_str(&format!("  {root_disp}"));
                }
                if let Some(ref last) = w.last_used_at {
                    let last_disp = tail_truncate_to_width(last, 26);
                    line.push_str(&format!("  last={last_disp}"));
                }
                ListItem::new(Line::from(line))
            })
            .collect::<Vec<_>>()
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn render_thread_picker_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title("Threads (Up/Down, Enter select, Esc cancel, Ctrl+N new)");
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(1)])
        .split(inner);

    let list = render_thread_picker(app);
    f.render_stateful_widget(list, rows[0], &mut app.thread_state);

    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[1]);
}

fn render_thread_picker(app: &App) -> List<'static> {
    let items = if app.thread_options.is_empty() {
        vec![ListItem::new("No threads loaded.")]
    } else {
        app.thread_options
            .iter()
            .map(|t| {
                let pin_indicator = if t.is_pinned { "📌 " } else { "" };
                let mode_str = t.mode.as_deref().unwrap_or("chat");
                let mut line = format!("{}{} [{}] ({} msgs)", pin_indicator, t.title, mode_str, t.message_count);
                if let Some(ref last) = t.last_message_at {
                    let last_disp = tail_truncate_to_width(last, 26);
                    line.push_str(&format!(" • {}", last_disp));
                }
                ListItem::new(Line::from(line))
            })
            .collect::<Vec<_>>()
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn render_memory_panel_popup(f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let status = if app.memory_enabled { "ON" } else { "OFF" };
    let title = format!(
        "Memory {} • {} items (Ctrl+M toggle, Del delete, Esc close)",
        status,
        app.memory_items.len()
    );

    let mut block = Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title(title);
    if app.cute != CuteMode::Off {
        block = block.border_style(Style::default().fg(c_sparkle()));
    }

    let inner = block.inner(area);
    f.render_widget(block, area);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(1), Constraint::Length(2), Constraint::Length(1)])
        .split(inner);

    // Memory list
    let list = render_memory_list(app);
    f.render_stateful_widget(list, rows[0], &mut app.memory_state);

    // Settings info
    let settings_text = format!(
        "Settings: Max {} items, {} tokens context • Scope: {}{}",
        app.memory_settings.max_items_injected,
        app.memory_settings.max_context_tokens,
        if app.memory_settings.include_global { "global" } else { "" },
        if app.memory_settings.include_project { "+project" } else { "" }
    );
    let settings_para = Paragraph::new(Line::from(vec![Span::styled(
        settings_text,
        Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
    )]))
    .alignment(Alignment::Center);
    f.render_widget(settings_para, rows[1]);

    // Stargazing bar
    let stargazing = render_stargazing_bar(app);
    f.render_widget(stargazing, rows[2]);
}

fn render_memory_list(app: &App) -> List<'static> {
    let items = if app.memory_items.is_empty() {
        vec![ListItem::new("No memory items yet. Memory will be captured automatically.")]
    } else {
        app.memory_items
            .iter()
            .map(|m| {
                let enabled_indicator = if m.enabled { "✓" } else { "✗" };
                let type_badge = match m.item_type.as_str() {
                    "fact" => "📝",
                    "preference" => "⭐",
                    "decision" => "🎯",
                    "constraint" => "🔒",
                    "todo" => "☑",
                    _ => "•",
                };

                // Truncate content for display
                let content_disp = if m.content.len() > 80 {
                    format!("{}...", &m.content[..77])
                } else {
                    m.content.clone()
                };

                let line = format!(
                    "{} {} [{}] {} (salience: {:.2}, {})",
                    enabled_indicator,
                    type_badge,
                    m.scope,
                    content_disp,
                    m.salience,
                    m.source
                );

                ListItem::new(Line::from(line))
            })
            .collect::<Vec<_>>()
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn render_choice_list(app: &App) -> List<'static> {
    let items = if let Some(ref prompt) = app.choice_prompt {
        if prompt.options.is_empty() {
            vec![ListItem::new("No options.")]
        } else {
            prompt
                .options
                .iter()
                .map(|o| {
                    let mut lines = Vec::new();
                    lines.push(Line::from(vec![Span::styled(
                        o.label.clone(),
                        Style::default().add_modifier(Modifier::BOLD),
                    )]));
                    if !o.description.trim().is_empty() {
                        lines.push(Line::from(vec![Span::styled(
                            o.description.clone(),
                            Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
                        )]));
                    }
                    ListItem::new(Text::from(lines))
                })
                .collect::<Vec<_>>()
        }
    } else {
        vec![ListItem::new("No active prompt.")]
    };

    List::new(items)
        .highlight_style(
            Style::default()
                .bg(c_sparkle())
                .fg(Color::Black)
                .add_modifier(Modifier::BOLD),
        )
}

fn border_blink_spans(step: u64, area_width: u16) -> Vec<Span<'static>> {
    // A small star cluster that travels right and "repairs" (re-draws) the border behind it.
    // No bullets/dots: empty space is the actual border line char.
    let usable = area_width.saturating_sub(2) as usize; // exclude corners
    if usable < 6 {
        return Vec::new();
    }

    const HEAD_W: usize = 5;
    let max_offset = usable.saturating_sub(HEAD_W);

    let grow_len = 5usize;
    let shrink_len = 4usize;
    // After reaching 5 stars, move the cluster right for ~15 steps (clamped to fit).
    let slide_len = 15usize.min(max_offset.saturating_sub(grow_len + shrink_len - 1));
    let total = grow_len + slide_len + shrink_len;
    if total == 0 {
        return Vec::new();
    }

    let idx = (step as usize) % total;

    let (offset, stars, right_align) = if idx < grow_len {
        // "Grow" diagonally: 1..5 stars.
        (idx, idx + 1, false)
    } else if idx < grow_len + slide_len {
        // Slide right with 5 stars.
        (idx, 5, false)
    } else {
        // Shrink: 4..1 stars, right-aligned inside the 5-cell head.
        let sidx = idx - (grow_len + slide_len);
        (idx, 4usize.saturating_sub(sidx), true)
    };

    let offset = offset.min(max_offset);

    let border_style = Style::default().fg(c_sparkle());
    let rainbow = |slot: usize| -> Color {
        match slot {
            0 => Color::Rgb(239, 68, 68),   // red
            1 => c_star(),                  // yellow
            2 => Color::Rgb(34, 197, 94),   // green
            3 => Color::Rgb(59, 130, 246),  // blue
            _ => c_brand(),                 // purple
        }
    };

    let mut spans: Vec<Span<'static>> = Vec::new();

    // Re-draw the border behind the moving head.
    if offset > 0 {
        spans.push(Span::styled("─".repeat(offset), border_style));
    }

    for i in 0..HEAD_W {
        let in_star = if stars == 0 {
            false
        } else if right_align {
            i >= HEAD_W.saturating_sub(stars)
        } else {
            i < stars
        };
        if in_star {
            spans.push(Span::styled(
                "★",
                Style::default()
                    .fg(rainbow(i))
                    .add_modifier(Modifier::BOLD),
            ));
        } else {
            spans.push(Span::styled("─", border_style));
        }
    }

    spans
}

fn render_help(_app: &App) -> Paragraph<'static> {
    let lines = vec![
        Line::from(vec![Span::styled(
            "Starbot TUI",
            Style::default().add_modifier(Modifier::BOLD),
        )]),
        Line::from(""),
        Line::from("Enter: send prompt"),
        Line::from("Esc: quit (or close popup)"),
        Line::from("F2 / Ctrl+M: model picker"),
        Line::from("F3: workspace picker"),
        Line::from("Ctrl+R: reload"),
        Line::from("PgUp/PgDn: scroll chat"),
        Line::from("Ctrl+D: toggle debug panel"),
        Line::from(""),
        Line::from("Choice modal: Tab/↑↓ select, Enter choose, Esc cancel"),
        Line::from("Input modal: type, Enter submit, Esc cancel"),
        Line::from(""),
        Line::from(
            "If chat says missing token: run `starbott auth login` or use scripts/starbott-dev.sh.",
        ),
    ];

    Paragraph::new(Text::from(lines))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title("Help (Esc to close)"),
        )
        .wrap(Wrap { trim: false })
}

fn render_tool_card(app: &App) -> Paragraph<'static> {
    let mut lines = Vec::new();

    if let Some(ref tool) = app.pending_tool {
        lines.push(Line::from(vec![Span::styled(
            format!("Tool: {}", tool.tool_name),
            Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD),
        )]));
        lines.push(Line::from(""));

        if !tool.target_files.is_empty() {
            lines.push(Line::from(vec![Span::styled(
                "Files:",
                Style::default().add_modifier(Modifier::BOLD),
            )]));
            for file in &tool.target_files {
                lines.push(Line::from(format!("  {file}")));
            }
            lines.push(Line::from(""));
        }

        if !tool.preview.is_empty() {
            lines.push(Line::from(vec![Span::styled(
                "Preview:",
                Style::default().add_modifier(Modifier::BOLD),
            )]));
            for preview_line in tool.preview.lines().take(15) {
                let style = if preview_line.starts_with('+') {
                    Style::default().fg(c_ok())
                } else if preview_line.starts_with('-') {
                    Style::default().fg(c_heart())
                } else {
                    Style::default().fg(c_muted())
                };
                lines.push(Line::from(vec![Span::styled(
                    format!("  {preview_line}"),
                    style,
                )]));
            }
            lines.push(Line::from(""));
        }

        if tool.requires_confirmation {
            lines.push(Line::from(vec![
                Span::styled("Y", Style::default().fg(c_ok()).add_modifier(Modifier::BOLD)),
                Span::raw(" Approve  "),
                Span::styled("N", Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
                Span::raw(" Deny"),
            ]));
        }
    } else {
        lines.push(Line::from("No pending tool."));
    }

    Paragraph::new(Text::from(lines))
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title("Tool Approval (Y/N)"),
        )
        .wrap(Wrap { trim: false })
}

fn seed_rng() -> u64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    now.as_nanos() as u64 ^ 0x9E37_79B9_7F4A_7C15u64
}

fn rand_idx(rng: &mut u64, len: usize) -> usize {
    if len == 0 {
        return 0;
    }
    *rng = rng
        .wrapping_mul(6364136223846793005)
        .wrapping_add(1442695040888963407);
    ((*rng >> 32) as usize) % len
}

fn pick_phrase(
    rng: &mut u64,
    last: &mut Option<&'static str>,
    pool: &'static [&'static str],
) -> &'static str {
    if pool.is_empty() {
        return "";
    }
    let mut idx = rand_idx(rng, pool.len());
    if let Some(prev) = last {
        if pool.len() > 1 && pool[idx] == *prev {
            idx = (idx + 1) % pool.len();
        }
    }
    let chosen = pool[idx];
    *last = Some(chosen);
    chosen
}

fn startup_status(cute: CuteMode) -> &'static str {
    match cute {
        CuteMode::On => "hi bestie ✨",
        CuteMode::Minimal => "Loading…",
        CuteMode::Off => "Loading models...",
    }
}

pub fn ready_status(cute: CuteMode, previous: &str) -> String {
    // Don't overwrite a more specific status (like an error).
    if previous.to_ascii_lowercase().contains("failed")
        || previous.to_ascii_lowercase().contains("error")
    {
        return previous.to_string();
    }
    match cute {
        CuteMode::On => "we're in, babe.".to_string(),
        CuteMode::Minimal => "Ready".to_string(),
        CuteMode::Off => "Ready.".to_string(),
    }
}

pub fn thinking_status(cute: CuteMode, rng: &mut u64, last: &mut Option<&'static str>) -> String {
    match cute {
        CuteMode::On => {
            const POOL: &[&str] = &[
                "stargazing... ✦",
                "consulting the constellations...",
                "picking the prettiest brain for this...",
            ];
            pick_phrase(rng, last, POOL).to_string()
        }
        CuteMode::Minimal => "Thinking...".to_string(),
        CuteMode::Off => "Sending...".to_string(),
    }
}

pub fn format_success_status(
    cute: CuteMode,
    rng: &mut u64,
    last: &mut Option<&'static str>,
    success_count: u32,
    lane: Option<Lane>,
) -> String {
    match cute {
        CuteMode::Off => "ok".to_string(),
        CuteMode::Minimal => "♡✓".to_string(),
        CuteMode::On => {
            let big_win = success_count == 1 || matches!(lane, Some(Lane::Deep));
            if big_win {
                // Keep this low frequency: first win, or deep lane.
                const POOL: &[&str] = &[
                    "verified. ♡ c'est bon.",
                    "sealed with a heart.",
                    "green flags only. ✓",
                ];
                let phrase = pick_phrase(rng, last, POOL);
                format!("♡✓ {phrase}")
            } else {
                "♡✓".to_string()
            }
        }
    }
}

pub fn format_error_status(
    cute: CuteMode,
    rng: &mut u64,
    last: &mut Option<&'static str>,
    err: &CliError,
) -> String {
    let (is_auth, is_scary) = match err {
        CliError::Auth(_) => (true, true),
        CliError::RateLimited(_) => (false, true),
        CliError::Server(_) => (false, true),
        _ => (false, false),
    };

    if is_auth {
        return "Authentication required. (No cute mode for auth errors.)".to_string();
    }

    let message = err.to_string();
    match cute {
        CuteMode::Off => format!("error: {message}"),
        CuteMode::Minimal => format!("♡! {message}"),
        CuteMode::On => {
            if is_scary {
                return format!("♡! {message}");
            }
            const POOL: &[&str] = &["oops - something's off.", "tiny detour, same destination."];
            let phrase = pick_phrase(rng, last, POOL);
            format!("♡! {phrase} {message}")
        }
    }
}

fn c_star() -> Color {
    Color::Rgb(255, 205, 86)
}

fn c_heart() -> Color {
    // Hot pink (closer to "actual pink" than red).
    Color::Rgb(255, 45, 149)
}

fn c_sparkle() -> Color {
    // Cyan.
    Color::Rgb(0, 255, 255)
}

fn c_brand() -> Color {
    Color::Rgb(131, 56, 236)
}

fn c_ok() -> Color {
    Color::Rgb(22, 163, 74)
}

fn c_warn() -> Color {
    Color::Rgb(245, 158, 11)
}

fn c_muted() -> Color {
    Color::Rgb(100, 116, 139)
}

// Base UI animation cadence. Keep stable; derive faster per-element animations
// from elapsed time rather than changing this (which would affect borders, etc.).
const SPINNER_INTERVAL_MS: u64 = 275;

fn update_spinner(app: &mut App) {
    if app.cute == CuteMode::Off {
        return;
    }
    if !app.waiting
        && app.bg_tasks == 0
        && !matches!(
            app.mode,
            Mode::ModelPicker | Mode::WorkspacePicker | Mode::ChoiceModal | Mode::TextPromptModal
        )
    {
        return;
    }
    // Keep this independent of the render loop cadence.
    // Speed: ~2x faster than the previous 550ms tick.
    let now = Instant::now();
    if now.duration_since(app.spinner_last) >= Duration::from_millis(SPINNER_INTERVAL_MS) {
        app.spinner_last = now;
        app.spinner_step = app.spinner_step.wrapping_add(1);
    }
}

fn header_left_line(app: &App) -> Line<'static> {
    match app.cute {
        CuteMode::Off => {
            let lane = lane_plain(app.lane);
            Line::from(vec![
                Span::raw(" Starbot  "),
                Span::styled(lane, Style::default().add_modifier(Modifier::DIM)),
                Span::raw(" "),
            ])
        }
        CuteMode::Minimal | CuteMode::On => {
            let mut spans: Vec<Span<'static>> = Vec::new();
            spans.push(Span::raw(" "));
            spans.extend(bespoke_star_spans());
            spans.push(Span::raw(" "));
            spans.push(Span::styled(
                "Starbot",
                Style::default()
                    .fg(c_brand())
                    .add_modifier(Modifier::BOLD),
            ));
            spans.push(Span::raw(" "));
            spans.push(Span::styled(
                "♡",
                Style::default().fg(c_heart()).add_modifier(Modifier::BOLD),
            ));
            spans.push(Span::raw("  "));
            spans.extend(lane_spans(app.lane));
            spans.push(Span::raw(" "));
            Line::from(spans)
        }
    }
}

fn bespoke_star_spans() -> Vec<Span<'static>> {
    vec![
        Span::styled("˖", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
        Span::styled("✦", Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)),
        Span::styled("★", Style::default().fg(c_star()).add_modifier(Modifier::BOLD)),
        Span::styled("✦", Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)),
        Span::styled("˖", Style::default().fg(c_muted()).add_modifier(Modifier::DIM)),
    ]
}

fn lane_plain(lane: Option<Lane>) -> String {
    match lane.unwrap_or(Lane::Standard) {
        Lane::Quick => "lane=quick".to_string(),
        Lane::Standard => "lane=standard".to_string(),
        Lane::Deep => "lane=deep".to_string(),
    }
}

fn lane_spans(lane: Option<Lane>) -> Vec<Span<'static>> {
    match lane.unwrap_or(Lane::Standard) {
        Lane::Quick => vec![Span::styled(
            "★",
            Style::default().fg(c_star()).add_modifier(Modifier::BOLD),
        )],
        Lane::Standard => vec![
            Span::styled("★", Style::default().fg(c_star()).add_modifier(Modifier::BOLD)),
            Span::styled("♡", Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
        ],
        Lane::Deep => vec![
            Span::styled("★", Style::default().fg(c_star()).add_modifier(Modifier::BOLD)),
            // "Wings" for deep lane (SPEC5) with a little sparkle tint.
            Span::styled("︵", Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)),
            Span::styled("♡", Style::default().fg(c_heart()).add_modifier(Modifier::BOLD)),
        ],
    }
}

fn provider_health_spans(cute: CuteMode, hints: ProviderHints) -> Vec<Span<'static>> {
    // Keep serious, short, and stable.
    let v = match hints.vertex_ok {
        Some(true) => ("OK", Some(true)),
        Some(false) => ("?", Some(false)),
        None => ("?", None),
    };
    let a = if hints.azure_present { ("OK", Some(true)) } else { ("?", None) };
    let cf = if hints.cf_present { ("OK", Some(true)) } else { ("?", None) };

    if cute == CuteMode::Off {
        return vec![Span::raw(format!(
            "Vertex:{} Azure:{} CF:{}",
            v.0, a.0, cf.0
        ))];
    }

    let status_style = |ok: Option<bool>| match ok {
        Some(true) => Style::default().fg(c_ok()).add_modifier(Modifier::BOLD),
        Some(false) => Style::default().fg(c_warn()).add_modifier(Modifier::BOLD),
        None => Style::default().fg(c_warn()).add_modifier(Modifier::BOLD),
    };

    vec![
        Span::styled("Vertex: ", Style::default().fg(c_muted())),
        Span::styled(v.0, status_style(v.1)),
        Span::raw("  "),
        Span::styled("Azure: ", Style::default().fg(c_muted())),
        Span::styled(a.0, status_style(a.1)),
        Span::raw("  "),
        Span::styled("CF: ", Style::default().fg(c_muted())),
        Span::styled(cf.0, status_style(cf.1)),
    ]
}

fn ping_pong_index(step: u64, len: usize) -> usize {
    if len <= 1 {
        return 0;
    }
    let period = (len - 1) * 2;
    let pos = (step as usize) % period;
    if pos < len {
        pos
    } else {
        period - pos
    }
}

fn spinner_spans(cute: CuteMode, lane: Option<Lane>, step: u64) -> Option<Vec<Span<'static>>> {
    if cute == CuteMode::Off {
        return None;
    }

    // From SPECS/LOADINGSPECS.md (multi-char, ping-pong loop).
    // Keep frame width stable so the status line doesn't jitter.
    const MAIN: &[&str] = &["✦ ★", "✦★ ", " ★✦", "★ ✦"];

    let _ = lane;
    let idx = ping_pong_index(step, MAIN.len());
    let frame = MAIN[idx];

    let modif = match cute {
        CuteMode::On => Modifier::BOLD,
        CuteMode::Minimal => Modifier::BOLD,
        CuteMode::Off => Modifier::empty(),
    };

    let mut spans: Vec<Span<'static>> = Vec::new();
    for ch in frame.chars() {
        let style = match ch {
            '★' => Style::default().fg(c_heart()).add_modifier(modif),
            '✦' | '✧' => Style::default().fg(c_sparkle()).add_modifier(modif),
            _ => Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
        };
        if ch == ' ' {
            spans.push(Span::raw(" "));
        } else {
            spans.push(Span::styled(ch.to_string(), style));
        }
    }

    Some(spans)
}

fn input_title_line(app: &App) -> Line<'static> {
    match app.cute {
        CuteMode::Off => {
            let lane = lane_plain(app.lane);
            if app.waiting {
                return Line::from(format!("Input (waiting)  {lane}  {}", app.status));
            }
            Line::from(format!("Input  {lane}  {}", app.status))
        }
        CuteMode::Minimal | CuteMode::On => {
            let mut spans: Vec<Span<'static>> = Vec::new();
            spans.push(Span::styled(
                "Input",
                Style::default().add_modifier(Modifier::BOLD),
            ));
            spans.push(Span::raw("  "));

            if app.waiting {
                if let Some(sp) = spinner_spans(app.cute, app.lane, app.spinner_step) {
                    spans.extend(sp);
                    spans.push(Span::raw("  "));
                }
            }

            spans.extend(lane_spans(app.lane));
            spans.push(Span::raw("  "));
            spans.push(Span::styled(app.status.clone(), Style::default().fg(c_muted())));
            Line::from(spans)
        }
    }
}

pub fn parse_lane(payload: &Value) -> Option<Lane> {
    payload
        .get("triage")
        .and_then(|v| v.get("lane"))
        .and_then(|v| v.as_str())
        .and_then(Lane::from_str)
}

pub fn parse_vertex_ok(payload: &Value) -> Option<bool> {
    let providers = payload.get("providers")?;
    let gemini = providers.get("gemini")?.as_str()?;
    match gemini {
        "available" => Some(true),
        "unavailable" => Some(false),
        _ => None,
    }
}

fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
    let popup_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);

    let vertical = popup_layout[1];
    let popup_layout = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(vertical);

    popup_layout[1]
}

fn build_chat_lines(
    messages: &[ChatMsg],
    width: usize,
    cute: CuteMode,
    spinner_step: u64,
    spinner_last: Instant,
) -> Vec<Line<'static>> {
    let mut out = Vec::new();

    // Startup splash: STARBOT wordmark + mascot.
    out.extend(startup_banner_lines(width, cute));

    for msg in messages {
        let typing = msg.role == ChatRole::Assistant && !msg.sendable;
        let (prefix_spans, content_style) = if cute == CuteMode::Off {
            let (tag, tag_style, content_style) = match msg.role {
                ChatRole::User => (
                    "You",
                    Style::default().fg(c_ok()).add_modifier(Modifier::BOLD),
                    Style::default(),
                ),
                ChatRole::Assistant => (
                    "AI",
                    Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD),
                    Style::default(),
                ),
                ChatRole::System => (
                    "SYS",
                    Style::default().fg(c_warn()).add_modifier(Modifier::BOLD),
                    Style::default().add_modifier(Modifier::DIM),
                ),
            };

            let prefix = format!("[{tag}] ");
            (vec![Span::styled(prefix, tag_style)], content_style)
        } else {
            match msg.role {
                ChatRole::User => (
                    vec![Span::styled(
                        "♡ ",
                        Style::default().fg(c_heart()).add_modifier(Modifier::BOLD),
                    )],
                    Style::default(),
                ),
                ChatRole::Assistant => {
                    (
                        assistant_prefix_spans(cute, spinner_step, typing),
                        if typing {
                            assistant_typing_style()
                        } else {
                            Style::default()
                        },
                    )
                }
                ChatRole::System => (
                    vec![Span::styled(
                        "💬 ",
                        Style::default().fg(c_muted()).add_modifier(Modifier::DIM),
                    )],
                    Style::default().add_modifier(Modifier::DIM),
                ),
            }
        };

        let prefix_len = spans_width(&prefix_spans);
        let content_raw = if typing {
            assistant_typing_content(spinner_step, spinner_last)
        } else {
            msg.content.as_str()
        };
        let content = content_raw.replace("\r\n", "\n");
        let indent = " ".repeat(prefix_len);
        let avail = width.saturating_sub(prefix_len).max(1);

        for (idx, line) in content.split('\n').enumerate() {
            let remaining = line.trim_end_matches('\r');
            if idx == 0 {
                if remaining.is_empty() {
                    out.push(Line::from(prefix_spans.clone()));
                    continue;
                }
                let wrapped = wrap_line(remaining, avail);
                for (widx, part) in wrapped.into_iter().enumerate() {
                    if widx == 0 {
                        let mut spans = Vec::new();
                        spans.extend(prefix_spans.clone());
                        spans.push(Span::styled(part, content_style));
                        out.push(Line::from(spans));
                    } else {
                        out.push(Line::from(vec![
                            Span::raw(indent.clone()),
                            Span::styled(part, content_style),
                        ]));
                    }
                }
            } else {
                if remaining.is_empty() {
                    out.push(Line::from(""));
                    continue;
                }
                let wrapped = wrap_line(remaining, avail);
                for part in wrapped {
                    out.push(Line::from(vec![
                        Span::raw(indent.clone()),
                        Span::styled(part, content_style),
                    ]));
                }
            }
        }

        // spacer line between messages
        out.push(Line::from(""));
    }
    out
}

fn startup_banner_lines(width: usize, cute: CuteMode) -> Vec<Line<'static>> {
    // From SPECS/SPEC12.md (mascot art). Keep as-is (Unicode); terminals/fonts that
    // support braille/block symbols will render it nicely.
    const MASCOT: &[&str] = &[
        "⠀⠀⠀⢀⣠⣤⣤⣤⣀⠀⠀⠀⠀⣀⣠⣤⣤⣤⣄⡀⠀⠀⠀⠀⠀",
        "⠀⠀⣠⣿⠿⠛⠛⠛⠛⠛⢿⣷⣤⣾⠿⠛⠛⠙⠛⠛⠿⠗⠀⠀⠀⠀",
        "⠀⣾⡿⠁⠀⠀⠀⠀⠀⠀⠀⠙⡿⠁⠀⢀⣤⣀⠀⠀⢀⣤⣶⡆⠀⠀",
        "⢸⣿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀",
        "⠸⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣧⣄⠀",
        "⠀⢹⣿⠀⣿⣷⣄⣀⣤⡄⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⠷",
        "⠀⠀⣁⣤⣿⣿⣿⣿⣿⠃⠀⠀⠀⠀⠘⠛⠛⠛⠻⣿⣿⣿⠋⠉⠀⠀",
        "⠀⠘⠻⢿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⢀⡀⠹⣿⡟⠀⠀⠀⠀",
        "⠀⠀⠀⠀⢹⣿⠟⢙⠛⠛⠀⠀⠀⠀⠀⣀⣴⡿⠓⠀⠀⠀⠀⠀⠀⠀",
        "⠀⠀⠀⠀⠈⠁⠀⠈⠻⢿⣦⣄⠀⣠⣾⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀",
        "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢿⣿⠿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
        "⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀",
    ];

    // Wordmark (requested). Keep as-is to preserve the look.
    const LOGO: &[&str] = &[
        "★   *      .       .   *   .   *",
        "   .     *      .    ★     .      .",
        "        ███████╗████████╗ █████╗ ██████╗ ██████╗  ██████╗ ████████╗",
        "        ██╔════╝╚══██╔══╝██╔══██╗██╔══██╗██╔══██╗██╔═══██╗╚══██╔══╝",
        "        ███████╗   ██║   ███████║██████╔╝██████╔╝██║   ██║   ██║",
        "        ╚════██║   ██║   ██╔══██║██╔══██╗██╔══██╗██║   ██║   ██║",
        "  ★     ███████║   ██║   ██║  ██║██║  ██║██████╔╝╚██████╔╝   ██║     ★",
        "        ╚══════╝   ╚═╝   ╚═╝  ╚═╝╚═╝  ╚═╝╚═════╝  ╚═════╝    ╚═╝",
        "    .    *    .   *      .   *   .   *",
    ];

    let mut out = Vec::new();

    let logo_w = LOGO
        .iter()
        .map(|l| l.trim_end().width())
        .max()
        .unwrap_or(0);
    let mascot_w = MASCOT.iter().map(|l| l.width()).max().unwrap_or(0);
    let min_gap = if logo_w == 0 || mascot_w == 0 { 0 } else { 3 };
    let combined_w = logo_w + min_gap + mascot_w;

    let logo_style_for_row = |row: usize| -> Style {
        if cute == CuteMode::Off {
            return Style::default();
        }
        // Starfield lines + the "ambient" row.
        if row <= 1 || row + 1 == LOGO.len() {
            return Style::default().fg(c_muted()).add_modifier(Modifier::DIM);
        }
        // Big block wordmark.
        Style::default().fg(c_sparkle()).add_modifier(Modifier::BOLD)
    };

    let mascot_style = if cute == CuteMode::Off {
        Style::default()
    } else {
        Style::default().fg(c_muted()).add_modifier(Modifier::DIM)
    };

    // Prefer side-by-side (logo left, mascot right). If the terminal is too narrow,
    // fall back to stacked so the art doesn't wrap.
    if width < combined_w {
        // ── Stacked ──
        let logo_pad = width.saturating_sub(logo_w) / 2;
        for (row, line) in LOGO.iter().enumerate() {
            out.push(Line::from(vec![
                Span::raw(" ".repeat(logo_pad)),
                Span::styled((*line).to_string(), logo_style_for_row(row)),
            ]));
        }
        out.push(Line::from(""));
        let mascot_pad = width.saturating_sub(mascot_w) / 2;
        for line in MASCOT {
            out.push(Line::from(vec![
                Span::raw(" ".repeat(mascot_pad)),
                Span::styled((*line).to_string(), mascot_style),
            ]));
        }
        out.push(Line::from(""));
        return out;
    }

    // ── Side-by-side ──
    let total_h = LOGO.len().max(MASCOT.len());

    for row in 0..total_h {
        let logo_line = LOGO.get(row).copied().unwrap_or("").trim_end();
        let logo_w_row = logo_line.width();
        let mascot_line = MASCOT.get(row).copied().unwrap_or("");

        // Logo is left-aligned; mascot is right-aligned.
        // Insert spaces so the mascot's max-width block ends at the right edge.
        let space_between = width
            .saturating_sub(mascot_w)
            .saturating_sub(logo_w_row);

        out.push(Line::from(vec![
            Span::styled(logo_line.to_string(), logo_style_for_row(row)),
            Span::raw(" ".repeat(space_between)),
            Span::styled(mascot_line.to_string(), mascot_style),
        ]));
    }

    out.push(Line::from(""));
    out
}

fn wrap_line(line: &str, width: usize) -> Vec<String> {
    if width <= 1 {
        return vec![line.to_string()];
    }
    let mut out = Vec::new();
    let mut cur = String::new();

    for word in line.split_whitespace() {
        if cur.is_empty() {
            cur.push_str(word);
            continue;
        }
        if cur.as_str().width() + 1 + word.width() <= width {
            cur.push(' ');
            cur.push_str(word);
        } else {
            out.push(cur);
            cur = word.to_string();
        }
    }

    if !cur.is_empty() {
        out.push(cur);
    }

    if out.is_empty() {
        out.push(String::new());
    }

    out
}

fn truncate_to_width(input: &str, max_width: usize) -> String {
    if max_width == 0 {
        return String::new();
    }
    if input.width() <= max_width {
        return input.to_string();
    }

    const ELLIPSIS: &str = "…";
    let ell_w = ELLIPSIS.width();
    if max_width <= ell_w {
        return ELLIPSIS.to_string();
    }

    let mut out = String::new();
    let mut w = 0usize;
    for ch in input.chars() {
        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
        if w + cw + ell_w > max_width {
            break;
        }
        out.push(ch);
        w += cw;
    }
    out.push_str(ELLIPSIS);
    out
}

fn tail_truncate_to_width(input: &str, max_width: usize) -> String {
    if max_width == 0 {
        return String::new();
    }
    if input.width() <= max_width {
        return input.to_string();
    }

    const ELLIPSIS: &str = "…";
    if max_width == 1 {
        return ELLIPSIS.to_string();
    }
    if max_width == 2 {
        return "..".to_string();
    }

    let keep_w = max_width.saturating_sub(2);
    let mut tail_rev = String::new();
    let mut w = 0usize;
    for ch in input.chars().rev() {
        let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
        if w + cw > keep_w {
            break;
        }
        tail_rev.push(ch);
        w += cw;
    }
    let tail: String = tail_rev.chars().rev().collect();
    format!("..{tail}")
}

fn input_prompt_len(app: &App) -> usize {
    input_prompt_prefix(app.cute).width()
}

fn spans_width(spans: &[Span<'_>]) -> usize {
    spans.iter().map(|s| s.content.as_ref().width()).sum()
}

fn truncate_spans_to_width(spans: &[Span<'static>], max_width: usize) -> Vec<Span<'static>> {
    if max_width == 0 {
        return Vec::new();
    }
    if spans_width(spans) <= max_width {
        return spans.to_vec();
    }

    const ELLIPSIS: &str = "…";
    let ell_w = ELLIPSIS.width();
    if max_width <= ell_w {
        return vec![Span::raw(ELLIPSIS)];
    }

    let mut out: Vec<Span<'static>> = Vec::new();
    let mut used = 0usize;
    let limit = max_width.saturating_sub(ell_w);

    for sp in spans {
        let style = sp.style;
        let text = sp.content.as_ref();
        if text.is_empty() {
            continue;
        }

        let mut chunk = String::new();
        for ch in text.chars() {
            let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
            if used + cw > limit {
                break;
            }
            chunk.push(ch);
            used += cw;
        }

        if !chunk.is_empty() {
            out.push(Span::styled(chunk, style));
        }

        if used >= limit {
            break;
        }
    }

    out.push(Span::raw(ELLIPSIS));
    out
}

fn compose_lr_line(
    total_width: usize,
    left: &[Span<'static>],
    right: &[Span<'static>],
) -> Line<'static> {
    if total_width == 0 {
        return Line::from("");
    }

    let right_w = spans_width(right);
    if right_w == 0 {
        return Line::from(truncate_spans_to_width(left, total_width));
    }
    if right_w >= total_width {
        return Line::from(truncate_spans_to_width(right, total_width));
    }

    let max_left = total_width.saturating_sub(right_w).saturating_sub(1);
    let left_trunc = truncate_spans_to_width(left, max_left);
    let left_w = spans_width(&left_trunc);
    let spaces = total_width.saturating_sub(left_w + right_w);

    let mut spans: Vec<Span<'static>> = Vec::new();
    spans.extend(left_trunc);
    if spaces > 0 {
        spans.push(Span::raw(" ".repeat(spaces)));
    }
    spans.extend(right.iter().cloned());
    Line::from(spans)
}

fn input_prompt_prefix(cute: CuteMode) -> &'static str {
    if cute == CuteMode::Off {
        ""
    } else {
        "♡ "
    }
}

fn assistant_prefix_spans(cute: CuteMode, step: u64, typing: bool) -> Vec<Span<'static>> {
    let _ = (cute, step, typing);
    // Keep prefix width stable but remove the old star+sparkle glyphs.
    let star = Span::styled("⭐", Style::default().fg(c_star()).add_modifier(Modifier::BOLD));
    vec![Span::raw(" "), star, Span::raw(" ")]
}

fn assistant_typing_content(spinner_step: u64, spinner_last: Instant) -> &'static str {
    // Single-cell glyph cycle (loop).
    //
    // Note: don't reuse `spinner_step` directly (275ms cadence). We derive a
    // faster tick from "approx elapsed time" so this animates smoothly without
    // speeding up other UI animations (border, etc.).
    const FRAMES: &[&str] = &["⣤", "⣰", "⢸", "⠹", "⠛", "⠏", "⡇", "⣆"];
    const TYPING_INTERVAL_MS: u128 = 60;

    let now = Instant::now();
    let elapsed_ms = (spinner_step as u128) * (SPINNER_INTERVAL_MS as u128)
        + now.duration_since(spinner_last).as_millis();
    let idx = ((elapsed_ms / TYPING_INTERVAL_MS) % (FRAMES.len() as u128)) as usize;
    FRAMES[idx]
}

fn assistant_typing_style() -> Style {
    // Single solid color (no gradient).
    Style::default()
        .fg(c_muted())
        .add_modifier(Modifier::BOLD)
}

pub fn remove_typing_placeholder(app: &mut App) {
    if let Some(idx) = app
        .messages
        .iter()
        .rposition(|m| m.role == ChatRole::Assistant && !m.sendable)
    {
        app.messages.remove(idx);
    }
}

fn format_bytes(bytes: u64) -> String {
    if bytes < 1024 {
        return format!("{bytes} B");
    }
    let kb = (bytes as f64) / 1024.0;
    if kb < 1024.0 {
        return format!("{:.1} KB", kb);
    }
    let mb = kb / 1024.0;
    format!("{:.1} MB", mb)
}

pub fn format_dir_listing_for_user(result: &Value) -> Option<String> {
    let path = result
        .get("path")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or(".");
    let entries = result.get("entries")?.as_array()?;
    let truncated = result.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false);

    let mut dirs: Vec<(String, Option<u64>)> = Vec::new();
    let mut files: Vec<(String, Option<u64>)> = Vec::new();
    let mut other: Vec<(String, Option<u64>)> = Vec::new();

    for e in entries {
        let name = e
            .get("name")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .unwrap_or("");
        if name.is_empty() {
            continue;
        }
        let typ = e
            .get("type")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .unwrap_or("other");
        let bytes = e.get("bytes").and_then(|v| v.as_u64());

        match typ {
            "dir" => dirs.push((name.to_string(), bytes)),
            "file" => files.push((name.to_string(), bytes)),
            _ => other.push((name.to_string(), bytes)),
        }
    }

    let mut out: Vec<String> = Vec::new();
    out.push("Auto tool: file.dir".to_string());
    out.push(String::new());
    out.push(format!(
        "Directory listing for `{}` ({} entries{}):",
        path,
        entries.len(),
        if truncated { ", truncated" } else { "" }
    ));

    let mut show_group = |label: &str, arr: &[(String, Option<u64>)], is_dir: bool| {
        if arr.is_empty() {
            return;
        }
        out.push(String::new());
        out.push(format!("{label}:"));
        for (name, bytes) in arr.iter().take(50) {
            let mut line = String::new();
            line.push_str("- ");
            line.push_str(name);
            if is_dir && !name.ends_with('/') {
                line.push('/');
            }
            if let Some(b) = bytes {
                line.push_str(&format!(" ({})", format_bytes(*b)));
            }
            out.push(line);
        }
        if arr.len() > 50 {
            out.push("- ...".to_string());
        }
    };

    show_group("Folders", &dirs, true);
    show_group("Files", &files, false);
    show_group("Other", &other, false);

    out.push(String::new());
    out.push(format!("Truncated: {}.", if truncated { "yes" } else { "no" }));

    Some(out.join("\n"))
}

fn truncate_chars(input: &str, max_chars: usize) -> (String, bool) {
    if max_chars == 0 {
        return (String::new(), input.chars().next().is_some());
    }
    let mut out = String::new();
    let mut count = 0usize;
    for ch in input.chars() {
        if count >= max_chars {
            return (out, true);
        }
        out.push(ch);
        count += 1;
    }
    (out, false)
}

pub fn format_file_read_for_user(result: &Value) -> Option<String> {
    let path = result
        .get("path")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .unwrap_or("-");
    let detected = result
        .get("detectedType")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .unwrap_or("text");
    let truncated = result.get("truncated").and_then(|v| v.as_bool()).unwrap_or(false);
    let total_bytes = result.get("totalBytes").and_then(|v| v.as_u64()).unwrap_or(0);
    let line_start = result.get("lineStart").and_then(|v| v.as_u64()).unwrap_or(1);
    let line_end = result.get("lineEnd").and_then(|v| v.as_u64()).unwrap_or(1);
    let content = result.get("content")?.as_str().unwrap_or("");

    let (snippet, clipped) = truncate_chars(content, 8000);

    let mut out: Vec<String> = Vec::new();
    out.push("Auto tool: file.read".to_string());
    out.push(String::new());
    out.push(format!(
        "File: `{}` (type={}, lines {}-{}, bytes={}, truncated={})",
        path,
        detected,
        line_start,
        line_end,
        total_bytes,
        if truncated { "yes" } else { "no" }
    ));
    out.push(String::new());
    out.push(format!("```{detected}"));
    out.push(snippet);
    if clipped {
        out.push("\n…(truncated)".to_string());
    }
    out.push("```".to_string());

    Some(out.join("\n"))
}

pub fn format_tool_propose_result(tool_name: &str, payload: &Value) -> String {
    let requires_confirmation = payload
        .get("requiresConfirmation")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);

    if requires_confirmation {
        let preview = payload.get("preview").cloned().unwrap_or_else(|| json!({}));
        let preview_text =
            serde_json::to_string_pretty(&preview).unwrap_or_else(|_| preview.to_string());
        let (snippet, clipped) = truncate_chars(&preview_text, 8000);
        let mut out = vec![
            format!("Tool proposal (requires confirmation): {tool_name}"),
            String::new(),
            snippet,
        ];
        if clipped {
            out.push("…(truncated)".to_string());
        }
        out.push(String::new());
        out.push("This tool requires confirmation; choices only support safe tools.".to_string());
        return out.join("\n");
    }

    let run_id = payload
        .get("runId")
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .unwrap_or("-");
    let result = payload.get("result").cloned().unwrap_or_else(|| json!({}));

    let mut out: Vec<String> = Vec::new();
    out.push(format!("Tool result: {tool_name} (runId: {run_id})"));
    out.push(String::new());

    if tool_name == "file.dir" {
        if let Some(listing) = format_dir_listing_for_user(&result) {
            out.push(listing);
        } else {
            out.push(result.to_string());
        }
        return out.join("\n");
    }

    if tool_name == "file.read" {
        if let Some(text) = format_file_read_for_user(&result) {
            out.push(text);
        } else {
            out.push(result.to_string());
        }
        return out.join("\n");
    }

    let text = serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string());
    let (snippet, clipped) = truncate_chars(&text, 8000);
    out.push(snippet);
    if clipped {
        out.push("…(truncated)".to_string());
    }
    out.join("\n")
}

mod.rs

Path: starbot_tui/src/parse/mod.rs

Size: 18 bytes

pub mod response;

response.rs

Path: starbot_tui/src/parse/response.rs

Size: 2166 bytes

// PHASE 3: Shared response parsing functions
// Extracted from tui.rs and chat.rs to eliminate 60 lines of duplication

use serde_json::Value;

/// Extract the reply text from an API response
pub fn extract_reply(payload: &Value) -> Option<String> {
    if let Some(reply) = payload.get("reply").and_then(|v| v.as_str()) {
        return Some(reply.to_string());
    }
    payload
        .get("message")
        .and_then(|v| v.get("content"))
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
}

/// Extract provider and model names from an API response
pub fn extract_provider_model(payload: &Value) -> (Option<String>, Option<String>) {
    let provider = payload
        .get("provider")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .or_else(|| {
            payload
                .get("message")
                .and_then(|v| v.get("provider"))
                .and_then(|v| v.as_str())
                .map(|s| s.to_string())
        });
    let model = payload
        .get("model")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .or_else(|| {
            payload
                .get("message")
                .and_then(|v| v.get("model"))
                .and_then(|v| v.as_str())
                .map(|s| s.to_string())
        });
    (provider, model)
}

/// Extract usage statistics and format as a display string
pub fn extract_usage_line(payload: &Value) -> String {
    if let Some(usage) = payload.get("usage") {
        let input = usage
            .get("inputTokens")
            .or_else(|| usage.get("input_tokens"))
            .and_then(|v| v.as_u64())
            .unwrap_or(0);
        let output = usage
            .get("outputTokens")
            .or_else(|| usage.get("output_tokens"))
            .and_then(|v| v.as_u64())
            .unwrap_or(0);
        let total = usage
            .get("totalTokens")
            .or_else(|| usage.get("total_tokens"))
            .and_then(|v| v.as_u64())
            .unwrap_or(input + output);
        return format!("usage(input={input}, output={output}, total={total})");
    }
    "usage(unknown)".to_string()
}

next-env.d.ts

Path: starbot_cloud/next-env.d.ts

Size: 247 bytes

/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

.gitignore

Path: starbot_cloud/.gitignore

Size: 480 bytes

# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

Dockerfile

Path: starbot_cloud/Dockerfile

Size: 300 bytes

FROM node:22-alpine

WORKDIR /app

# Copy standalone build
COPY .next/standalone ./
COPY .next/static ./.next/static
COPY public ./public

# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# Expose web port
EXPOSE 3000

# Run Next.js
CMD ["node", "server.js"]

deploy.sh

Path: starbot_cloud/deploy.sh

Size: 2878 bytes

#!/bin/bash
# Deploy Starbot WebGUI
# Usage: ./deploy.sh
set -e

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo -e "${YELLOW}Deploying Starbot WebGUI from $REPO_DIR${NC}"

# Step 1: Install dependencies and build
echo -e "\n${GREEN}1. Building WebGUI...${NC}"
sudo chown -R starbot:starbot "$REPO_DIR"
sudo -u starbot bash -lc "cd '$REPO_DIR' && rm -rf .next && npm ci && npm run build"

if [ ! -f "$REPO_DIR/.next/standalone/server.js" ]; then
    echo -e "${RED}Build failed - .next/standalone/server.js not found${NC}"
    echo -e "${YELLOW}Make sure output: 'standalone' is set in next.config.ts${NC}"
    exit 1
fi
echo -e "${GREEN}Build successful${NC}"

# Step 2: Copy static assets into standalone directory
echo -e "\n${GREEN}2. Publishing static assets...${NC}"
STANDALONE_DIR="$REPO_DIR/.next/standalone"
sudo -u starbot rsync -a "$REPO_DIR/.next/static/" "$STANDALONE_DIR/.next/static/"
sudo -u starbot rsync -a "$REPO_DIR/public/" "$STANDALONE_DIR/public/"
echo -e "${GREEN}Static assets published${NC}"

# Step 3: Install systemd service
echo -e "\n${GREEN}3. Installing systemd service...${NC}"
sudo cp "$REPO_DIR/deploy/starbot-webgui.service" /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable starbot-webgui
echo -e "${GREEN}Service installed${NC}"

# Step 4: Install nginx config
echo -e "\n${GREEN}4. Installing nginx config...${NC}"
if [ -f "$REPO_DIR/deploy/nginx-starbot.cloud.conf" ]; then
    sudo cp "$REPO_DIR/deploy/nginx-starbot.cloud.conf" /etc/nginx/sites-available/starbot.cloud
    # IMPORTANT: nginx includes sites-enabled/*.conf - the symlink MUST end in .conf
    sudo rm -f /etc/nginx/sites-enabled/starbot.cloud
    sudo ln -sf /etc/nginx/sites-available/starbot.cloud /etc/nginx/sites-enabled/starbot.cloud.conf
    if sudo nginx -t; then
        sudo systemctl reload nginx
        echo -e "${GREEN}Nginx config installed and reloaded${NC}"
    else
        echo -e "${RED}Nginx config test failed${NC}"
    fi
fi

# Step 5: Restart
echo -e "\n${GREEN}5. Restarting starbot-webgui...${NC}"
if sudo ss -ltnp | grep -q ':3001 '; then
    echo -e "${YELLOW}Port 3001 in use, releasing stale listener...${NC}"
    sudo fuser -k 3001/tcp || true
    sleep 1
fi
sudo systemctl restart starbot-webgui
sleep 2

if sudo systemctl is-active --quiet starbot-webgui; then
    echo -e "${GREEN}starbot-webgui is running${NC}"
else
    echo -e "${RED}starbot-webgui failed to start${NC}"
    echo -e "${YELLOW}View logs: sudo journalctl -u starbot-webgui -n 50${NC}"
    exit 1
fi

# Step 6: Verify
echo -e "\n${GREEN}6. Verifying...${NC}"
if curl -sf http://localhost:3001 > /dev/null; then
    echo -e "${GREEN}WebGUI responding${NC}"
else
    echo -e "${RED}WebGUI not responding${NC}"
fi

echo -e "\n${GREEN}WebGUI deployment complete!${NC}"

package.json

Path: starbot_cloud/package.json

Size: 1328 bytes

{
  "name": "starbot-web",
  "version": "0.1.0",
  "private": true,
  "engines": {
    "node": ">=20.9.0"
  },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@dnd-kit/core": "^6.3.1",
    "@dnd-kit/sortable": "^10.0.0",
    "@dnd-kit/utilities": "^3.2.2",
    "@radix-ui/react-dialog": "^1.1.15",
    "@radix-ui/react-dropdown-menu": "^2.1.16",
    "@radix-ui/react-slot": "^1.2.4",
    "@radix-ui/react-tooltip": "^1.2.8",
    "@tanstack/react-query": "^5.90.21",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "highlight.js": "^11.11.1",
    "lucide-react": "^0.563.0",
    "next": "16.1.6",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "react-markdown": "^10.1.0",
    "rehype-highlight": "^7.0.2",
    "rehype-raw": "^7.0.0",
    "remark-gfm": "^4.0.1",
    "sonner": "^2.0.7",
    "tailwind-merge": "^3.4.0",
    "zod": "^4.3.6",
    "zustand": "^5.0.11"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@tailwindcss/typography": "^0.5.19",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "babel-plugin-react-compiler": "1.0.0",
    "eslint": "^9",
    "eslint-config-next": "16.1.6",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

package-lock.json

Path: starbot_cloud/package-lock.json

Size: 334985 bytes

{
  "name": "starbot-web",
  "version": "0.1.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "starbot-web",
      "version": "0.1.0",
      "dependencies": {
        "@dnd-kit/core": "^6.3.1",
        "@dnd-kit/sortable": "^10.0.0",
        "@dnd-kit/utilities": "^3.2.2",
        "@radix-ui/react-dialog": "^1.1.15",
        "@radix-ui/react-dropdown-menu": "^2.1.16",
        "@radix-ui/react-slot": "^1.2.4",
        "@radix-ui/react-tooltip": "^1.2.8",
        "@tanstack/react-query": "^5.90.21",
        "class-variance-authority": "^0.7.1",
        "clsx": "^2.1.1",
        "highlight.js": "^11.11.1",
        "lucide-react": "^0.563.0",
        "next": "16.1.6",
        "react": "19.2.3",
        "react-dom": "19.2.3",
        "react-markdown": "^10.1.0",
        "rehype-highlight": "^7.0.2",
        "rehype-raw": "^7.0.0",
        "remark-gfm": "^4.0.1",
        "sonner": "^2.0.7",
        "tailwind-merge": "^3.4.0",
        "zod": "^4.3.6",
        "zustand": "^5.0.11"
      },
      "devDependencies": {
        "@tailwindcss/postcss": "^4",
        "@tailwindcss/typography": "^0.5.19",
        "@types/node": "^20",
        "@types/react": "^19",
        "@types/react-dom": "^19",
        "babel-plugin-react-compiler": "1.0.0",
        "eslint": "^9",
        "eslint-config-next": "16.1.6",
        "tailwindcss": "^4",
        "typescript": "^5"
      },
      "engines": {
        "node": ">=20.9.0"
      }
    },
    "node_modules/@alloc/quick-lru": {
      "version": "5.2.0",
      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/@babel/code-frame": {
      "version": "7.29.0",
      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
      "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-validator-identifier": "^7.28.5",
        "js-tokens": "^4.0.0",
        "picocolors": "^1.1.1"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/compat-data": {
      "version": "7.29.0",
      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
      "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/core": {
      "version": "7.29.0",
      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
      "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/code-frame": "^7.29.0",
        "@babel/generator": "^7.29.0",
        "@babel/helper-compilation-targets": "^7.28.6",
        "@babel/helper-module-transforms": "^7.28.6",
        "@babel/helpers": "^7.28.6",
        "@babel/parser": "^7.29.0",
        "@babel/template": "^7.28.6",
        "@babel/traverse": "^7.29.0",
        "@babel/types": "^7.29.0",
        "@jridgewell/remapping": "^2.3.5",
        "convert-source-map": "^2.0.0",
        "debug": "^4.1.0",
        "gensync": "^1.0.0-beta.2",
        "json5": "^2.2.3",
        "semver": "^6.3.1"
      },
      "engines": {
        "node": ">=6.9.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/babel"
      }
    },
    "node_modules/@babel/generator": {
      "version": "7.29.1",
      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
      "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/parser": "^7.29.0",
        "@babel/types": "^7.29.0",
        "@jridgewell/gen-mapping": "^0.3.12",
        "@jridgewell/trace-mapping": "^0.3.28",
        "jsesc": "^3.0.2"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-compilation-targets": {
      "version": "7.28.6",
      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
      "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/compat-data": "^7.28.6",
        "@babel/helper-validator-option": "^7.27.1",
        "browserslist": "^4.24.0",
        "lru-cache": "^5.1.1",
        "semver": "^6.3.1"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-globals": {
      "version": "7.28.0",
      "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
      "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-module-imports": {
      "version": "7.28.6",
      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
      "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/traverse": "^7.28.6",
        "@babel/types": "^7.28.6"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-module-transforms": {
      "version": "7.28.6",
      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
      "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-module-imports": "^7.28.6",
        "@babel/helper-validator-identifier": "^7.28.5",
        "@babel/traverse": "^7.28.6"
      },
      "engines": {
        "node": ">=6.9.0"
      },
      "peerDependencies": {
        "@babel/core": "^7.0.0"
      }
    },
    "node_modules/@babel/helper-string-parser": {
      "version": "7.27.1",
      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
      "devOptional": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-validator-identifier": {
      "version": "7.28.5",
      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
      "devOptional": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-validator-option": {
      "version": "7.27.1",
      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
      "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helpers": {
      "version": "7.28.6",
      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
      "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/template": "^7.28.6",
        "@babel/types": "^7.28.6"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/parser": {
      "version": "7.29.0",
      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
      "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/types": "^7.29.0"
      },
      "bin": {
        "parser": "bin/babel-parser.js"
      },
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/@babel/template": {
      "version": "7.28.6",
      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
      "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/code-frame": "^7.28.6",
        "@babel/parser": "^7.28.6",
        "@babel/types": "^7.28.6"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/traverse": {
      "version": "7.29.0",
      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
      "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/code-frame": "^7.29.0",
        "@babel/generator": "^7.29.0",
        "@babel/helper-globals": "^7.28.0",
        "@babel/parser": "^7.29.0",
        "@babel/template": "^7.28.6",
        "@babel/types": "^7.29.0",
        "debug": "^4.3.1"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/types": {
      "version": "7.29.0",
      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
      "devOptional": true,
      "license": "MIT",
      "dependencies": {
        "@babel/helper-string-parser": "^7.27.1",
        "@babel/helper-validator-identifier": "^7.28.5"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@dnd-kit/accessibility": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
      "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
      "license": "MIT",
      "dependencies": {
        "tslib": "^2.0.0"
      },
      "peerDependencies": {
        "react": ">=16.8.0"
      }
    },
    "node_modules/@dnd-kit/core": {
      "version": "6.3.1",
      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
      "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
      "license": "MIT",
      "dependencies": {
        "@dnd-kit/accessibility": "^3.1.1",
        "@dnd-kit/utilities": "^3.2.2",
        "tslib": "^2.0.0"
      },
      "peerDependencies": {
        "react": ">=16.8.0",
        "react-dom": ">=16.8.0"
      }
    },
    "node_modules/@dnd-kit/sortable": {
      "version": "10.0.0",
      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
      "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
      "license": "MIT",
      "dependencies": {
        "@dnd-kit/utilities": "^3.2.2",
        "tslib": "^2.0.0"
      },
      "peerDependencies": {
        "@dnd-kit/core": "^6.3.0",
        "react": ">=16.8.0"
      }
    },
    "node_modules/@dnd-kit/utilities": {
      "version": "3.2.2",
      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
      "license": "MIT",
      "dependencies": {
        "tslib": "^2.0.0"
      },
      "peerDependencies": {
        "react": ">=16.8.0"
      }
    },
    "node_modules/@emnapi/core": {
      "version": "1.8.1",
      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
      "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
      "dev": true,
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "@emnapi/wasi-threads": "1.1.0",
        "tslib": "^2.4.0"
      }
    },
    "node_modules/@emnapi/runtime": {
      "version": "1.8.1",
      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
      "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "tslib": "^2.4.0"
      }
    },
    "node_modules/@emnapi/wasi-threads": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz",
      "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
      "dev": true,
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "tslib": "^2.4.0"
      }
    },
    "node_modules/@eslint-community/eslint-utils": {
      "version": "4.9.1",
      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
      "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "eslint-visitor-keys": "^3.4.3"
      },
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      },
      "peerDependencies": {
        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
      }
    },
    "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
      "version": "3.4.3",
      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/@eslint-community/regexpp": {
      "version": "4.12.2",
      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
      "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
      }
    },
    "node_modules/@eslint/config-array": {
      "version": "0.21.1",
      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz",
      "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@eslint/object-schema": "^2.1.7",
        "debug": "^4.3.1",
        "minimatch": "^3.1.2"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      }
    },
    "node_modules/@eslint/config-helpers": {
      "version": "0.4.2",
      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
      "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@eslint/core": "^0.17.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      }
    },
    "node_modules/@eslint/core": {
      "version": "0.17.0",
      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
      "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@types/json-schema": "^7.0.15"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      }
    },
    "node_modules/@eslint/eslintrc": {
      "version": "3.3.3",
      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
      "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ajv": "^6.12.4",
        "debug": "^4.3.2",
        "espree": "^10.0.1",
        "globals": "^14.0.0",
        "ignore": "^5.2.0",
        "import-fresh": "^3.2.1",
        "js-yaml": "^4.1.1",
        "minimatch": "^3.1.2",
        "strip-json-comments": "^3.1.1"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/@eslint/js": {
      "version": "9.39.2",
      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
      "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "url": "https://eslint.org/donate"
      }
    },
    "node_modules/@eslint/object-schema": {
      "version": "2.1.7",
      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
      "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      }
    },
    "node_modules/@eslint/plugin-kit": {
      "version": "0.4.1",
      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
      "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@eslint/core": "^0.17.0",
        "levn": "^0.4.1"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      }
    },
    "node_modules/@floating-ui/core": {
      "version": "1.7.4",
      "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
      "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
      "license": "MIT",
      "dependencies": {
        "@floating-ui/utils": "^0.2.10"
      }
    },
    "node_modules/@floating-ui/dom": {
      "version": "1.7.5",
      "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
      "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
      "license": "MIT",
      "dependencies": {
        "@floating-ui/core": "^1.7.4",
        "@floating-ui/utils": "^0.2.10"
      }
    },
    "node_modules/@floating-ui/react-dom": {
      "version": "2.1.7",
      "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz",
      "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==",
      "license": "MIT",
      "dependencies": {
        "@floating-ui/dom": "^1.7.5"
      },
      "peerDependencies": {
        "react": ">=16.8.0",
        "react-dom": ">=16.8.0"
      }
    },
    "node_modules/@floating-ui/utils": {
      "version": "0.2.10",
      "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
      "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
      "license": "MIT"
    },
    "node_modules/@humanfs/core": {
      "version": "0.19.1",
      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
      "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=18.18.0"
      }
    },
    "node_modules/@humanfs/node": {
      "version": "0.16.7",
      "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
      "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "@humanfs/core": "^0.19.1",
        "@humanwhocodes/retry": "^0.4.0"
      },
      "engines": {
        "node": ">=18.18.0"
      }
    },
    "node_modules/@humanwhocodes/module-importer": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=12.22"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/nzakas"
      }
    },
    "node_modules/@humanwhocodes/retry": {
      "version": "0.4.3",
      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=18.18"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/nzakas"
      }
    },
    "node_modules/@img/colour": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
      "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
      "license": "MIT",
      "optional": true,
      "engines": {
        "node": ">=18"
      }
    },
    "node_modules/@img/sharp-darwin-arm64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-darwin-arm64": "1.2.4"
      }
    },
    "node_modules/@img/sharp-darwin-x64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-darwin-x64": "1.2.4"
      }
    },
    "node_modules/@img/sharp-libvips-darwin-arm64": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
      "cpu": [
        "arm64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "darwin"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-darwin-x64": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
      "cpu": [
        "x64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "darwin"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-arm": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
      "cpu": [
        "arm"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-arm64": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
      "cpu": [
        "arm64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-ppc64": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
      "cpu": [
        "ppc64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-riscv64": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
      "cpu": [
        "riscv64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-s390x": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
      "cpu": [
        "s390x"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-x64": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
      "cpu": [
        "x64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
      "cpu": [
        "arm64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
      "cpu": [
        "x64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-linux-arm": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
      "cpu": [
        "arm"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-arm": "1.2.4"
      }
    },
    "node_modules/@img/sharp-linux-arm64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-arm64": "1.2.4"
      }
    },
    "node_modules/@img/sharp-linux-ppc64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
      "cpu": [
        "ppc64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-ppc64": "1.2.4"
      }
    },
    "node_modules/@img/sharp-linux-riscv64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
      "cpu": [
        "riscv64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-riscv64": "1.2.4"
      }
    },
    "node_modules/@img/sharp-linux-s390x": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
      "cpu": [
        "s390x"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-s390x": "1.2.4"
      }
    },
    "node_modules/@img/sharp-linux-x64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-x64": "1.2.4"
      }
    },
    "node_modules/@img/sharp-linuxmusl-arm64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
      }
    },
    "node_modules/@img/sharp-linuxmusl-x64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
      }
    },
    "node_modules/@img/sharp-wasm32": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
      "cpu": [
        "wasm32"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
      "optional": true,
      "dependencies": {
        "@emnapi/runtime": "^1.7.0"
      },
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-win32-arm64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-win32-ia32": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
      "cpu": [
        "ia32"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-win32-x64": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@jridgewell/gen-mapping": {
      "version": "0.3.13",
      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/sourcemap-codec": "^1.5.0",
        "@jridgewell/trace-mapping": "^0.3.24"
      }
    },
    "node_modules/@jridgewell/remapping": {
      "version": "2.3.5",
      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/gen-mapping": "^0.3.5",
        "@jridgewell/trace-mapping": "^0.3.24"
      }
    },
    "node_modules/@jridgewell/resolve-uri": {
      "version": "3.1.2",
      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.0.0"
      }
    },
    "node_modules/@jridgewell/sourcemap-codec": {
      "version": "1.5.5",
      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@jridgewell/trace-mapping": {
      "version": "0.3.31",
      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/resolve-uri": "^3.1.0",
        "@jridgewell/sourcemap-codec": "^1.4.14"
      }
    },
    "node_modules/@napi-rs/wasm-runtime": {
      "version": "0.2.12",
      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
      "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
      "dev": true,
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "@emnapi/core": "^1.4.3",
        "@emnapi/runtime": "^1.4.3",
        "@tybys/wasm-util": "^0.10.0"
      }
    },
    "node_modules/@next/env": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
      "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
      "license": "MIT"
    },
    "node_modules/@next/eslint-plugin-next": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz",
      "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fast-glob": "3.3.1"
      }
    },
    "node_modules/@next/swc-darwin-arm64": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
      "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@next/swc-darwin-x64": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
      "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@next/swc-linux-arm64-gnu": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
      "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@next/swc-linux-arm64-musl": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
      "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@next/swc-linux-x64-gnu": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
      "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@next/swc-linux-x64-musl": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
      "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@next/swc-win32-arm64-msvc": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
      "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
      "cpu": [
        "arm64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@next/swc-win32-x64-msvc": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
      "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
      "cpu": [
        "x64"
      ],
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@nodelib/fs.scandir": {
      "version": "2.1.5",
      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@nodelib/fs.stat": "2.0.5",
        "run-parallel": "^1.1.9"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/@nodelib/fs.stat": {
      "version": "2.0.5",
      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/@nodelib/fs.walk": {
      "version": "1.2.8",
      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@nodelib/fs.scandir": "2.1.5",
        "fastq": "^1.6.0"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/@nolyfill/is-core-module": {
      "version": "1.0.39",
      "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz",
      "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12.4.0"
      }
    },
    "node_modules/@radix-ui/primitive": {
      "version": "1.1.3",
      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
      "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
      "license": "MIT"
    },
    "node_modules/@radix-ui/react-arrow": {
      "version": "1.1.7",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
      "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-primitive": "2.1.3"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-collection": {
      "version": "1.1.7",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
      "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-context": "1.1.2",
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-slot": "1.2.3"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-compose-refs": "1.1.2"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-compose-refs": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
      "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
      "license": "MIT",
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-context": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
      "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
      "license": "MIT",
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-dialog": {
      "version": "1.1.15",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
      "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/primitive": "1.1.3",
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-context": "1.1.2",
        "@radix-ui/react-dismissable-layer": "1.1.11",
        "@radix-ui/react-focus-guards": "1.1.3",
        "@radix-ui/react-focus-scope": "1.1.7",
        "@radix-ui/react-id": "1.1.1",
        "@radix-ui/react-portal": "1.1.9",
        "@radix-ui/react-presence": "1.1.5",
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-slot": "1.2.3",
        "@radix-ui/react-use-controllable-state": "1.2.2",
        "aria-hidden": "^1.2.4",
        "react-remove-scroll": "^2.6.3"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-compose-refs": "1.1.2"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-direction": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
      "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
      "license": "MIT",
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-dismissable-layer": {
      "version": "1.1.11",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
      "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/primitive": "1.1.3",
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-use-callback-ref": "1.1.1",
        "@radix-ui/react-use-escape-keydown": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-dropdown-menu": {
      "version": "2.1.16",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz",
      "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/primitive": "1.1.3",
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-context": "1.1.2",
        "@radix-ui/react-id": "1.1.1",
        "@radix-ui/react-menu": "2.1.16",
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-use-controllable-state": "1.2.2"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-focus-guards": {
      "version": "1.1.3",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
      "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
      "license": "MIT",
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-focus-scope": {
      "version": "1.1.7",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
      "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-use-callback-ref": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-id": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
      "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-use-layout-effect": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-menu": {
      "version": "2.1.16",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz",
      "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/primitive": "1.1.3",
        "@radix-ui/react-collection": "1.1.7",
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-context": "1.1.2",
        "@radix-ui/react-direction": "1.1.1",
        "@radix-ui/react-dismissable-layer": "1.1.11",
        "@radix-ui/react-focus-guards": "1.1.3",
        "@radix-ui/react-focus-scope": "1.1.7",
        "@radix-ui/react-id": "1.1.1",
        "@radix-ui/react-popper": "1.2.8",
        "@radix-ui/react-portal": "1.1.9",
        "@radix-ui/react-presence": "1.1.5",
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-roving-focus": "1.1.11",
        "@radix-ui/react-slot": "1.2.3",
        "@radix-ui/react-use-callback-ref": "1.1.1",
        "aria-hidden": "^1.2.4",
        "react-remove-scroll": "^2.6.3"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-compose-refs": "1.1.2"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-popper": {
      "version": "1.2.8",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
      "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
      "license": "MIT",
      "dependencies": {
        "@floating-ui/react-dom": "^2.0.0",
        "@radix-ui/react-arrow": "1.1.7",
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-context": "1.1.2",
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-use-callback-ref": "1.1.1",
        "@radix-ui/react-use-layout-effect": "1.1.1",
        "@radix-ui/react-use-rect": "1.1.1",
        "@radix-ui/react-use-size": "1.1.1",
        "@radix-ui/rect": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-portal": {
      "version": "1.1.9",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
      "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-use-layout-effect": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-presence": {
      "version": "1.1.5",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
      "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-use-layout-effect": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-primitive": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
      "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-slot": "1.2.3"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-compose-refs": "1.1.2"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-roving-focus": {
      "version": "1.1.11",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
      "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/primitive": "1.1.3",
        "@radix-ui/react-collection": "1.1.7",
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-context": "1.1.2",
        "@radix-ui/react-direction": "1.1.1",
        "@radix-ui/react-id": "1.1.1",
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-use-callback-ref": "1.1.1",
        "@radix-ui/react-use-controllable-state": "1.2.2"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-slot": {
      "version": "1.2.4",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
      "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-compose-refs": "1.1.2"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-tooltip": {
      "version": "1.2.8",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
      "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/primitive": "1.1.3",
        "@radix-ui/react-compose-refs": "1.1.2",
        "@radix-ui/react-context": "1.1.2",
        "@radix-ui/react-dismissable-layer": "1.1.11",
        "@radix-ui/react-id": "1.1.1",
        "@radix-ui/react-popper": "1.2.8",
        "@radix-ui/react-portal": "1.1.9",
        "@radix-ui/react-presence": "1.1.5",
        "@radix-ui/react-primitive": "2.1.3",
        "@radix-ui/react-slot": "1.2.3",
        "@radix-ui/react-use-controllable-state": "1.2.2",
        "@radix-ui/react-visually-hidden": "1.2.3"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-compose-refs": "1.1.2"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-use-callback-ref": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
      "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
      "license": "MIT",
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-use-controllable-state": {
      "version": "1.2.2",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
      "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-use-effect-event": "0.0.2",
        "@radix-ui/react-use-layout-effect": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-use-effect-event": {
      "version": "0.0.2",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
      "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-use-layout-effect": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-use-escape-keydown": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
      "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-use-callback-ref": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-use-layout-effect": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
      "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
      "license": "MIT",
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-use-rect": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
      "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/rect": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-use-size": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
      "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-use-layout-effect": "1.1.1"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/react-visually-hidden": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
      "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
      "license": "MIT",
      "dependencies": {
        "@radix-ui/react-primitive": "2.1.3"
      },
      "peerDependencies": {
        "@types/react": "*",
        "@types/react-dom": "*",
        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "@types/react-dom": {
          "optional": true
        }
      }
    },
    "node_modules/@radix-ui/rect": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
      "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
      "license": "MIT"
    },
    "node_modules/@rtsao/scc": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
      "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@swc/helpers": {
      "version": "0.5.15",
      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
      "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
      "license": "Apache-2.0",
      "dependencies": {
        "tslib": "^2.8.0"
      }
    },
    "node_modules/@tailwindcss/node": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
      "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/remapping": "^2.3.4",
        "enhanced-resolve": "^5.18.3",
        "jiti": "^2.6.1",
        "lightningcss": "1.30.2",
        "magic-string": "^0.30.21",
        "source-map-js": "^1.2.1",
        "tailwindcss": "4.1.18"
      }
    },
    "node_modules/@tailwindcss/oxide": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
      "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 10"
      },
      "optionalDependencies": {
        "@tailwindcss/oxide-android-arm64": "4.1.18",
        "@tailwindcss/oxide-darwin-arm64": "4.1.18",
        "@tailwindcss/oxide-darwin-x64": "4.1.18",
        "@tailwindcss/oxide-freebsd-x64": "4.1.18",
        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
        "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
        "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
        "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
        "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
        "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
        "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
        "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
      }
    },
    "node_modules/@tailwindcss/oxide-android-arm64": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
      "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-darwin-arm64": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
      "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-darwin-x64": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
      "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-freebsd-x64": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
      "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
      "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
      "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
      "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
      "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-linux-x64-musl": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
      "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
      "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
      "bundleDependencies": [
        "@napi-rs/wasm-runtime",
        "@emnapi/core",
        "@emnapi/runtime",
        "@tybys/wasm-util",
        "@emnapi/wasi-threads",
        "tslib"
      ],
      "cpu": [
        "wasm32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "@emnapi/core": "^1.7.1",
        "@emnapi/runtime": "^1.7.1",
        "@emnapi/wasi-threads": "^1.1.0",
        "@napi-rs/wasm-runtime": "^1.1.0",
        "@tybys/wasm-util": "^0.10.1",
        "tslib": "^2.4.0"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
      "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
      "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/@tailwindcss/postcss": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
      "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@alloc/quick-lru": "^5.2.0",
        "@tailwindcss/node": "4.1.18",
        "@tailwindcss/oxide": "4.1.18",
        "postcss": "^8.4.41",
        "tailwindcss": "4.1.18"
      }
    },
    "node_modules/@tailwindcss/typography": {
      "version": "0.5.19",
      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
      "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "postcss-selector-parser": "6.0.10"
      },
      "peerDependencies": {
        "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
      }
    },
    "node_modules/@tanstack/query-core": {
      "version": "5.90.20",
      "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
      "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/tannerlinsley"
      }
    },
    "node_modules/@tanstack/react-query": {
      "version": "5.90.21",
      "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
      "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
      "license": "MIT",
      "dependencies": {
        "@tanstack/query-core": "5.90.20"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/tannerlinsley"
      },
      "peerDependencies": {
        "react": "^18 || ^19"
      }
    },
    "node_modules/@tybys/wasm-util": {
      "version": "0.10.1",
      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
      "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
      "dev": true,
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "tslib": "^2.4.0"
      }
    },
    "node_modules/@types/debug": {
      "version": "4.1.12",
      "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
      "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
      "license": "MIT",
      "dependencies": {
        "@types/ms": "*"
      }
    },
    "node_modules/@types/estree": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
      "license": "MIT"
    },
    "node_modules/@types/estree-jsx": {
      "version": "1.0.5",
      "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
      "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
      "license": "MIT",
      "dependencies": {
        "@types/estree": "*"
      }
    },
    "node_modules/@types/hast": {
      "version": "3.0.4",
      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
      "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "*"
      }
    },
    "node_modules/@types/json-schema": {
      "version": "7.0.15",
      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/json5": {
      "version": "0.0.29",
      "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
      "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/@types/mdast": {
      "version": "4.0.4",
      "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
      "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "*"
      }
    },
    "node_modules/@types/ms": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
      "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
      "license": "MIT"
    },
    "node_modules/@types/node": {
      "version": "20.19.33",
      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
      "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "undici-types": "~6.21.0"
      }
    },
    "node_modules/@types/react": {
      "version": "19.2.14",
      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
      "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
      "license": "MIT",
      "dependencies": {
        "csstype": "^3.2.2"
      }
    },
    "node_modules/@types/react-dom": {
      "version": "19.2.3",
      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
      "devOptional": true,
      "license": "MIT",
      "peerDependencies": {
        "@types/react": "^19.2.0"
      }
    },
    "node_modules/@types/unist": {
      "version": "3.0.3",
      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
      "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
      "license": "MIT"
    },
    "node_modules/@typescript-eslint/eslint-plugin": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
      "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@eslint-community/regexpp": "^4.12.2",
        "@typescript-eslint/scope-manager": "8.55.0",
        "@typescript-eslint/type-utils": "8.55.0",
        "@typescript-eslint/utils": "8.55.0",
        "@typescript-eslint/visitor-keys": "8.55.0",
        "ignore": "^7.0.5",
        "natural-compare": "^1.4.0",
        "ts-api-utils": "^2.4.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "@typescript-eslint/parser": "^8.55.0",
        "eslint": "^8.57.0 || ^9.0.0",
        "typescript": ">=4.8.4 <6.0.0"
      }
    },
    "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
      "version": "7.0.5",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
      "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 4"
      }
    },
    "node_modules/@typescript-eslint/parser": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
      "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/scope-manager": "8.55.0",
        "@typescript-eslint/types": "8.55.0",
        "@typescript-eslint/typescript-estree": "8.55.0",
        "@typescript-eslint/visitor-keys": "8.55.0",
        "debug": "^4.4.3"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "eslint": "^8.57.0 || ^9.0.0",
        "typescript": ">=4.8.4 <6.0.0"
      }
    },
    "node_modules/@typescript-eslint/project-service": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz",
      "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/tsconfig-utils": "^8.55.0",
        "@typescript-eslint/types": "^8.55.0",
        "debug": "^4.4.3"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "typescript": ">=4.8.4 <6.0.0"
      }
    },
    "node_modules/@typescript-eslint/scope-manager": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz",
      "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/types": "8.55.0",
        "@typescript-eslint/visitor-keys": "8.55.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      }
    },
    "node_modules/@typescript-eslint/tsconfig-utils": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz",
      "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "typescript": ">=4.8.4 <6.0.0"
      }
    },
    "node_modules/@typescript-eslint/type-utils": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz",
      "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/types": "8.55.0",
        "@typescript-eslint/typescript-estree": "8.55.0",
        "@typescript-eslint/utils": "8.55.0",
        "debug": "^4.4.3",
        "ts-api-utils": "^2.4.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "eslint": "^8.57.0 || ^9.0.0",
        "typescript": ">=4.8.4 <6.0.0"
      }
    },
    "node_modules/@typescript-eslint/types": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz",
      "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      }
    },
    "node_modules/@typescript-eslint/typescript-estree": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz",
      "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/project-service": "8.55.0",
        "@typescript-eslint/tsconfig-utils": "8.55.0",
        "@typescript-eslint/types": "8.55.0",
        "@typescript-eslint/visitor-keys": "8.55.0",
        "debug": "^4.4.3",
        "minimatch": "^9.0.5",
        "semver": "^7.7.3",
        "tinyglobby": "^0.2.15",
        "ts-api-utils": "^2.4.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "typescript": ">=4.8.4 <6.0.0"
      }
    },
    "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "balanced-match": "^1.0.0"
      }
    },
    "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
      "version": "9.0.5",
      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "brace-expansion": "^2.0.1"
      },
      "engines": {
        "node": ">=16 || 14 >=14.17"
      },
      "funding": {
        "url": "https://github.com/sponsors/isaacs"
      }
    },
    "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
      "version": "7.7.4",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
      "dev": true,
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/@typescript-eslint/utils": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz",
      "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@eslint-community/eslint-utils": "^4.9.1",
        "@typescript-eslint/scope-manager": "8.55.0",
        "@typescript-eslint/types": "8.55.0",
        "@typescript-eslint/typescript-estree": "8.55.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "eslint": "^8.57.0 || ^9.0.0",
        "typescript": ">=4.8.4 <6.0.0"
      }
    },
    "node_modules/@typescript-eslint/visitor-keys": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz",
      "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/types": "8.55.0",
        "eslint-visitor-keys": "^4.2.1"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      }
    },
    "node_modules/@ungap/structured-clone": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
      "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
      "license": "ISC"
    },
    "node_modules/@unrs/resolver-binding-android-arm-eabi": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
      "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@unrs/resolver-binding-android-arm64": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz",
      "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "android"
      ]
    },
    "node_modules/@unrs/resolver-binding-darwin-arm64": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz",
      "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@unrs/resolver-binding-darwin-x64": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz",
      "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "darwin"
      ]
    },
    "node_modules/@unrs/resolver-binding-freebsd-x64": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz",
      "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "freebsd"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz",
      "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz",
      "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-arm64-gnu": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz",
      "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-arm64-musl": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz",
      "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz",
      "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==",
      "cpu": [
        "ppc64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz",
      "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-riscv64-musl": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz",
      "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==",
      "cpu": [
        "riscv64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-s390x-gnu": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz",
      "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==",
      "cpu": [
        "s390x"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-x64-gnu": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz",
      "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-linux-x64-musl": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz",
      "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "linux"
      ]
    },
    "node_modules/@unrs/resolver-binding-wasm32-wasi": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz",
      "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==",
      "cpu": [
        "wasm32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "@napi-rs/wasm-runtime": "^0.2.11"
      },
      "engines": {
        "node": ">=14.0.0"
      }
    },
    "node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz",
      "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@unrs/resolver-binding-win32-ia32-msvc": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz",
      "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==",
      "cpu": [
        "ia32"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/@unrs/resolver-binding-win32-x64-msvc": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz",
      "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MIT",
      "optional": true,
      "os": [
        "win32"
      ]
    },
    "node_modules/acorn": {
      "version": "8.15.0",
      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
      "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
      "dev": true,
      "license": "MIT",
      "bin": {
        "acorn": "bin/acorn"
      },
      "engines": {
        "node": ">=0.4.0"
      }
    },
    "node_modules/acorn-jsx": {
      "version": "5.3.2",
      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
      "dev": true,
      "license": "MIT",
      "peerDependencies": {
        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
      }
    },
    "node_modules/ajv": {
      "version": "6.12.6",
      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fast-deep-equal": "^3.1.1",
        "fast-json-stable-stringify": "^2.0.0",
        "json-schema-traverse": "^0.4.1",
        "uri-js": "^4.2.2"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/epoberezkin"
      }
    },
    "node_modules/ansi-styles": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "color-convert": "^2.0.1"
      },
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
      }
    },
    "node_modules/argparse": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
      "dev": true,
      "license": "Python-2.0"
    },
    "node_modules/aria-hidden": {
      "version": "1.2.6",
      "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
      "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
      "license": "MIT",
      "dependencies": {
        "tslib": "^2.0.0"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/aria-query": {
      "version": "5.3.2",
      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
      "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/array-buffer-byte-length": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
      "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "is-array-buffer": "^3.0.5"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/array-includes": {
      "version": "3.1.9",
      "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
      "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.4",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.24.0",
        "es-object-atoms": "^1.1.1",
        "get-intrinsic": "^1.3.0",
        "is-string": "^1.1.1",
        "math-intrinsics": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/array.prototype.findlast": {
      "version": "1.2.5",
      "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
      "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.7",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.2",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.0.0",
        "es-shim-unscopables": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/array.prototype.findlastindex": {
      "version": "1.2.6",
      "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz",
      "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.4",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.9",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "es-shim-unscopables": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/array.prototype.flat": {
      "version": "1.3.3",
      "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
      "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.5",
        "es-shim-unscopables": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/array.prototype.flatmap": {
      "version": "1.3.3",
      "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
      "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.5",
        "es-shim-unscopables": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/array.prototype.tosorted": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
      "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.7",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.3",
        "es-errors": "^1.3.0",
        "es-shim-unscopables": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/arraybuffer.prototype.slice": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
      "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "array-buffer-byte-length": "^1.0.1",
        "call-bind": "^1.0.8",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.5",
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.6",
        "is-array-buffer": "^3.0.4"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/ast-types-flow": {
      "version": "0.0.8",
      "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
      "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/async-function": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
      "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/available-typed-arrays": {
      "version": "1.0.7",
      "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
      "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "possible-typed-array-names": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/axe-core": {
      "version": "4.11.1",
      "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
      "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==",
      "dev": true,
      "license": "MPL-2.0",
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/axobject-query": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
      "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/babel-plugin-react-compiler": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
      "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
      "devOptional": true,
      "license": "MIT",
      "dependencies": {
        "@babel/types": "^7.26.0"
      }
    },
    "node_modules/bail": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
      "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/balanced-match": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/baseline-browser-mapping": {
      "version": "2.9.19",
      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
      "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
      "license": "Apache-2.0",
      "bin": {
        "baseline-browser-mapping": "dist/cli.js"
      }
    },
    "node_modules/brace-expansion": {
      "version": "1.1.12",
      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
      "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "balanced-match": "^1.0.0",
        "concat-map": "0.0.1"
      }
    },
    "node_modules/braces": {
      "version": "3.0.3",
      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fill-range": "^7.1.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/browserslist": {
      "version": "4.28.1",
      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
      "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
      "dev": true,
      "funding": [
        {
          "type": "opencollective",
          "url": "https://opencollective.com/browserslist"
        },
        {
          "type": "tidelift",
          "url": "https://tidelift.com/funding/github/npm/browserslist"
        },
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "baseline-browser-mapping": "^2.9.0",
        "caniuse-lite": "^1.0.30001759",
        "electron-to-chromium": "^1.5.263",
        "node-releases": "^2.0.27",
        "update-browserslist-db": "^1.2.0"
      },
      "bin": {
        "browserslist": "cli.js"
      },
      "engines": {
        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
      }
    },
    "node_modules/call-bind": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
      "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.0",
        "es-define-property": "^1.0.0",
        "get-intrinsic": "^1.2.4",
        "set-function-length": "^1.2.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/call-bind-apply-helpers": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/call-bound": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.2",
        "get-intrinsic": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/callsites": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/caniuse-lite": {
      "version": "1.0.30001769",
      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
      "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
      "funding": [
        {
          "type": "opencollective",
          "url": "https://opencollective.com/browserslist"
        },
        {
          "type": "tidelift",
          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
        },
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "CC-BY-4.0"
    },
    "node_modules/ccount": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
      "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/chalk": {
      "version": "4.1.2",
      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ansi-styles": "^4.1.0",
        "supports-color": "^7.1.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/chalk/chalk?sponsor=1"
      }
    },
    "node_modules/character-entities": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
      "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/character-entities-html4": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
      "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/character-entities-legacy": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
      "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/character-reference-invalid": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
      "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/class-variance-authority": {
      "version": "0.7.1",
      "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
      "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
      "license": "Apache-2.0",
      "dependencies": {
        "clsx": "^2.1.1"
      },
      "funding": {
        "url": "https://polar.sh/cva"
      }
    },
    "node_modules/client-only": {
      "version": "0.0.1",
      "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
      "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
      "license": "MIT"
    },
    "node_modules/clsx": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/color-convert": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "color-name": "~1.1.4"
      },
      "engines": {
        "node": ">=7.0.0"
      }
    },
    "node_modules/color-name": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/comma-separated-tokens": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
      "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/concat-map": {
      "version": "0.0.1",
      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/convert-source-map": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
      "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/cross-spawn": {
      "version": "7.0.6",
      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "path-key": "^3.1.0",
        "shebang-command": "^2.0.0",
        "which": "^2.0.1"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/cssesc": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
      "dev": true,
      "license": "MIT",
      "bin": {
        "cssesc": "bin/cssesc"
      },
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/csstype": {
      "version": "3.2.3",
      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
      "license": "MIT"
    },
    "node_modules/damerau-levenshtein": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
      "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
      "dev": true,
      "license": "BSD-2-Clause"
    },
    "node_modules/data-view-buffer": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
      "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "es-errors": "^1.3.0",
        "is-data-view": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/data-view-byte-length": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
      "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "es-errors": "^1.3.0",
        "is-data-view": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/inspect-js"
      }
    },
    "node_modules/data-view-byte-offset": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
      "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "es-errors": "^1.3.0",
        "is-data-view": "^1.0.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/debug": {
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
      "license": "MIT",
      "dependencies": {
        "ms": "^2.1.3"
      },
      "engines": {
        "node": ">=6.0"
      },
      "peerDependenciesMeta": {
        "supports-color": {
          "optional": true
        }
      }
    },
    "node_modules/decode-named-character-reference": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
      "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
      "license": "MIT",
      "dependencies": {
        "character-entities": "^2.0.0"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/deep-is": {
      "version": "0.1.4",
      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/define-data-property": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
      "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-define-property": "^1.0.0",
        "es-errors": "^1.3.0",
        "gopd": "^1.0.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/define-properties": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
      "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "define-data-property": "^1.0.1",
        "has-property-descriptors": "^1.0.0",
        "object-keys": "^1.1.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/dequal": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
      "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/detect-libc": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
      "devOptional": true,
      "license": "Apache-2.0",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/detect-node-es": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
      "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
      "license": "MIT"
    },
    "node_modules/devlop": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
      "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
      "license": "MIT",
      "dependencies": {
        "dequal": "^2.0.0"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/doctrine": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
      "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
      "dev": true,
      "license": "Apache-2.0",
      "dependencies": {
        "esutils": "^2.0.2"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/dunder-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.1",
        "es-errors": "^1.3.0",
        "gopd": "^1.2.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/electron-to-chromium": {
      "version": "1.5.286",
      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
      "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/emoji-regex": {
      "version": "9.2.2",
      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/enhanced-resolve": {
      "version": "5.19.0",
      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
      "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "graceful-fs": "^4.2.4",
        "tapable": "^2.3.0"
      },
      "engines": {
        "node": ">=10.13.0"
      }
    },
    "node_modules/entities": {
      "version": "6.0.1",
      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=0.12"
      },
      "funding": {
        "url": "https://github.com/fb55/entities?sponsor=1"
      }
    },
    "node_modules/es-abstract": {
      "version": "1.24.1",
      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
      "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "array-buffer-byte-length": "^1.0.2",
        "arraybuffer.prototype.slice": "^1.0.4",
        "available-typed-arrays": "^1.0.7",
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.4",
        "data-view-buffer": "^1.0.2",
        "data-view-byte-length": "^1.0.2",
        "data-view-byte-offset": "^1.0.1",
        "es-define-property": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "es-set-tostringtag": "^2.1.0",
        "es-to-primitive": "^1.3.0",
        "function.prototype.name": "^1.1.8",
        "get-intrinsic": "^1.3.0",
        "get-proto": "^1.0.1",
        "get-symbol-description": "^1.1.0",
        "globalthis": "^1.0.4",
        "gopd": "^1.2.0",
        "has-property-descriptors": "^1.0.2",
        "has-proto": "^1.2.0",
        "has-symbols": "^1.1.0",
        "hasown": "^2.0.2",
        "internal-slot": "^1.1.0",
        "is-array-buffer": "^3.0.5",
        "is-callable": "^1.2.7",
        "is-data-view": "^1.0.2",
        "is-negative-zero": "^2.0.3",
        "is-regex": "^1.2.1",
        "is-set": "^2.0.3",
        "is-shared-array-buffer": "^1.0.4",
        "is-string": "^1.1.1",
        "is-typed-array": "^1.1.15",
        "is-weakref": "^1.1.1",
        "math-intrinsics": "^1.1.0",
        "object-inspect": "^1.13.4",
        "object-keys": "^1.1.1",
        "object.assign": "^4.1.7",
        "own-keys": "^1.0.1",
        "regexp.prototype.flags": "^1.5.4",
        "safe-array-concat": "^1.1.3",
        "safe-push-apply": "^1.0.0",
        "safe-regex-test": "^1.1.0",
        "set-proto": "^1.0.0",
        "stop-iteration-iterator": "^1.1.0",
        "string.prototype.trim": "^1.2.10",
        "string.prototype.trimend": "^1.0.9",
        "string.prototype.trimstart": "^1.0.8",
        "typed-array-buffer": "^1.0.3",
        "typed-array-byte-length": "^1.0.3",
        "typed-array-byte-offset": "^1.0.4",
        "typed-array-length": "^1.0.7",
        "unbox-primitive": "^1.1.0",
        "which-typed-array": "^1.1.19"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/es-define-property": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-errors": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-iterator-helpers": {
      "version": "1.2.2",
      "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz",
      "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.4",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.24.1",
        "es-errors": "^1.3.0",
        "es-set-tostringtag": "^2.1.0",
        "function-bind": "^1.1.2",
        "get-intrinsic": "^1.3.0",
        "globalthis": "^1.0.4",
        "gopd": "^1.2.0",
        "has-property-descriptors": "^1.0.2",
        "has-proto": "^1.2.0",
        "has-symbols": "^1.1.0",
        "internal-slot": "^1.1.0",
        "iterator.prototype": "^1.1.5",
        "safe-array-concat": "^1.1.3"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-object-atoms": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-set-tostringtag": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.6",
        "has-tostringtag": "^1.0.2",
        "hasown": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-shim-unscopables": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
      "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "hasown": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/es-to-primitive": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
      "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-callable": "^1.2.7",
        "is-date-object": "^1.0.5",
        "is-symbol": "^1.0.4"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/escalade": {
      "version": "3.2.0",
      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/escape-string-regexp": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/eslint": {
      "version": "9.39.2",
      "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
      "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@eslint-community/eslint-utils": "^4.8.0",
        "@eslint-community/regexpp": "^4.12.1",
        "@eslint/config-array": "^0.21.1",
        "@eslint/config-helpers": "^0.4.2",
        "@eslint/core": "^0.17.0",
        "@eslint/eslintrc": "^3.3.1",
        "@eslint/js": "9.39.2",
        "@eslint/plugin-kit": "^0.4.1",
        "@humanfs/node": "^0.16.6",
        "@humanwhocodes/module-importer": "^1.0.1",
        "@humanwhocodes/retry": "^0.4.2",
        "@types/estree": "^1.0.6",
        "ajv": "^6.12.4",
        "chalk": "^4.0.0",
        "cross-spawn": "^7.0.6",
        "debug": "^4.3.2",
        "escape-string-regexp": "^4.0.0",
        "eslint-scope": "^8.4.0",
        "eslint-visitor-keys": "^4.2.1",
        "espree": "^10.4.0",
        "esquery": "^1.5.0",
        "esutils": "^2.0.2",
        "fast-deep-equal": "^3.1.3",
        "file-entry-cache": "^8.0.0",
        "find-up": "^5.0.0",
        "glob-parent": "^6.0.2",
        "ignore": "^5.2.0",
        "imurmurhash": "^0.1.4",
        "is-glob": "^4.0.0",
        "json-stable-stringify-without-jsonify": "^1.0.1",
        "lodash.merge": "^4.6.2",
        "minimatch": "^3.1.2",
        "natural-compare": "^1.4.0",
        "optionator": "^0.9.3"
      },
      "bin": {
        "eslint": "bin/eslint.js"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "url": "https://eslint.org/donate"
      },
      "peerDependencies": {
        "jiti": "*"
      },
      "peerDependenciesMeta": {
        "jiti": {
          "optional": true
        }
      }
    },
    "node_modules/eslint-config-next": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz",
      "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@next/eslint-plugin-next": "16.1.6",
        "eslint-import-resolver-node": "^0.3.6",
        "eslint-import-resolver-typescript": "^3.5.2",
        "eslint-plugin-import": "^2.32.0",
        "eslint-plugin-jsx-a11y": "^6.10.0",
        "eslint-plugin-react": "^7.37.0",
        "eslint-plugin-react-hooks": "^7.0.0",
        "globals": "16.4.0",
        "typescript-eslint": "^8.46.0"
      },
      "peerDependencies": {
        "eslint": ">=9.0.0",
        "typescript": ">=3.3.1"
      },
      "peerDependenciesMeta": {
        "typescript": {
          "optional": true
        }
      }
    },
    "node_modules/eslint-config-next/node_modules/globals": {
      "version": "16.4.0",
      "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
      "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=18"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/eslint-import-resolver-node": {
      "version": "0.3.9",
      "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
      "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "debug": "^3.2.7",
        "is-core-module": "^2.13.0",
        "resolve": "^1.22.4"
      }
    },
    "node_modules/eslint-import-resolver-node/node_modules/debug": {
      "version": "3.2.7",
      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ms": "^2.1.1"
      }
    },
    "node_modules/eslint-import-resolver-typescript": {
      "version": "3.10.1",
      "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz",
      "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "@nolyfill/is-core-module": "1.0.39",
        "debug": "^4.4.0",
        "get-tsconfig": "^4.10.0",
        "is-bun-module": "^2.0.0",
        "stable-hash": "^0.0.5",
        "tinyglobby": "^0.2.13",
        "unrs-resolver": "^1.6.2"
      },
      "engines": {
        "node": "^14.18.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint-import-resolver-typescript"
      },
      "peerDependencies": {
        "eslint": "*",
        "eslint-plugin-import": "*",
        "eslint-plugin-import-x": "*"
      },
      "peerDependenciesMeta": {
        "eslint-plugin-import": {
          "optional": true
        },
        "eslint-plugin-import-x": {
          "optional": true
        }
      }
    },
    "node_modules/eslint-module-utils": {
      "version": "2.12.1",
      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
      "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "debug": "^3.2.7"
      },
      "engines": {
        "node": ">=4"
      },
      "peerDependenciesMeta": {
        "eslint": {
          "optional": true
        }
      }
    },
    "node_modules/eslint-module-utils/node_modules/debug": {
      "version": "3.2.7",
      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ms": "^2.1.1"
      }
    },
    "node_modules/eslint-plugin-import": {
      "version": "2.32.0",
      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
      "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@rtsao/scc": "^1.1.0",
        "array-includes": "^3.1.9",
        "array.prototype.findlastindex": "^1.2.6",
        "array.prototype.flat": "^1.3.3",
        "array.prototype.flatmap": "^1.3.3",
        "debug": "^3.2.7",
        "doctrine": "^2.1.0",
        "eslint-import-resolver-node": "^0.3.9",
        "eslint-module-utils": "^2.12.1",
        "hasown": "^2.0.2",
        "is-core-module": "^2.16.1",
        "is-glob": "^4.0.3",
        "minimatch": "^3.1.2",
        "object.fromentries": "^2.0.8",
        "object.groupby": "^1.0.3",
        "object.values": "^1.2.1",
        "semver": "^6.3.1",
        "string.prototype.trimend": "^1.0.9",
        "tsconfig-paths": "^3.15.0"
      },
      "engines": {
        "node": ">=4"
      },
      "peerDependencies": {
        "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
      }
    },
    "node_modules/eslint-plugin-import/node_modules/debug": {
      "version": "3.2.7",
      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "ms": "^2.1.1"
      }
    },
    "node_modules/eslint-plugin-jsx-a11y": {
      "version": "6.10.2",
      "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
      "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "aria-query": "^5.3.2",
        "array-includes": "^3.1.8",
        "array.prototype.flatmap": "^1.3.2",
        "ast-types-flow": "^0.0.8",
        "axe-core": "^4.10.0",
        "axobject-query": "^4.1.0",
        "damerau-levenshtein": "^1.0.8",
        "emoji-regex": "^9.2.2",
        "hasown": "^2.0.2",
        "jsx-ast-utils": "^3.3.5",
        "language-tags": "^1.0.9",
        "minimatch": "^3.1.2",
        "object.fromentries": "^2.0.8",
        "safe-regex-test": "^1.0.3",
        "string.prototype.includes": "^2.0.1"
      },
      "engines": {
        "node": ">=4.0"
      },
      "peerDependencies": {
        "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
      }
    },
    "node_modules/eslint-plugin-react": {
      "version": "7.37.5",
      "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
      "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "array-includes": "^3.1.8",
        "array.prototype.findlast": "^1.2.5",
        "array.prototype.flatmap": "^1.3.3",
        "array.prototype.tosorted": "^1.1.4",
        "doctrine": "^2.1.0",
        "es-iterator-helpers": "^1.2.1",
        "estraverse": "^5.3.0",
        "hasown": "^2.0.2",
        "jsx-ast-utils": "^2.4.1 || ^3.0.0",
        "minimatch": "^3.1.2",
        "object.entries": "^1.1.9",
        "object.fromentries": "^2.0.8",
        "object.values": "^1.2.1",
        "prop-types": "^15.8.1",
        "resolve": "^2.0.0-next.5",
        "semver": "^6.3.1",
        "string.prototype.matchall": "^4.0.12",
        "string.prototype.repeat": "^1.0.0"
      },
      "engines": {
        "node": ">=4"
      },
      "peerDependencies": {
        "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
      }
    },
    "node_modules/eslint-plugin-react-hooks": {
      "version": "7.0.1",
      "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
      "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@babel/core": "^7.24.4",
        "@babel/parser": "^7.24.4",
        "hermes-parser": "^0.25.1",
        "zod": "^3.25.0 || ^4.0.0",
        "zod-validation-error": "^3.5.0 || ^4.0.0"
      },
      "engines": {
        "node": ">=18"
      },
      "peerDependencies": {
        "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
      }
    },
    "node_modules/eslint-plugin-react/node_modules/resolve": {
      "version": "2.0.0-next.5",
      "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
      "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-core-module": "^2.13.0",
        "path-parse": "^1.0.7",
        "supports-preserve-symlinks-flag": "^1.0.0"
      },
      "bin": {
        "resolve": "bin/resolve"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/eslint-scope": {
      "version": "8.4.0",
      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
      "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "esrecurse": "^4.3.0",
        "estraverse": "^5.2.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/eslint-visitor-keys": {
      "version": "4.2.1",
      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
      "dev": true,
      "license": "Apache-2.0",
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/espree": {
      "version": "10.4.0",
      "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
      "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "acorn": "^8.15.0",
        "acorn-jsx": "^5.3.2",
        "eslint-visitor-keys": "^4.2.1"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "url": "https://opencollective.com/eslint"
      }
    },
    "node_modules/esquery": {
      "version": "1.7.0",
      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
      "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
      "dev": true,
      "license": "BSD-3-Clause",
      "dependencies": {
        "estraverse": "^5.1.0"
      },
      "engines": {
        "node": ">=0.10"
      }
    },
    "node_modules/esrecurse": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "estraverse": "^5.2.0"
      },
      "engines": {
        "node": ">=4.0"
      }
    },
    "node_modules/estraverse": {
      "version": "5.3.0",
      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
      "dev": true,
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=4.0"
      }
    },
    "node_modules/estree-util-is-identifier-name": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
      "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
      "license": "MIT",
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/esutils": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
      "dev": true,
      "license": "BSD-2-Clause",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/extend": {
      "version": "3.0.2",
      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
      "license": "MIT"
    },
    "node_modules/fast-deep-equal": {
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fast-glob": {
      "version": "3.3.1",
      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
      "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@nodelib/fs.stat": "^2.0.2",
        "@nodelib/fs.walk": "^1.2.3",
        "glob-parent": "^5.1.2",
        "merge2": "^1.3.0",
        "micromatch": "^4.0.4"
      },
      "engines": {
        "node": ">=8.6.0"
      }
    },
    "node_modules/fast-glob/node_modules/glob-parent": {
      "version": "5.1.2",
      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "is-glob": "^4.0.1"
      },
      "engines": {
        "node": ">= 6"
      }
    },
    "node_modules/fast-json-stable-stringify": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fast-levenshtein": {
      "version": "2.0.6",
      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/fastq": {
      "version": "1.20.1",
      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "reusify": "^1.0.4"
      }
    },
    "node_modules/file-entry-cache": {
      "version": "8.0.0",
      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "flat-cache": "^4.0.0"
      },
      "engines": {
        "node": ">=16.0.0"
      }
    },
    "node_modules/fill-range": {
      "version": "7.1.1",
      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "to-regex-range": "^5.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/find-up": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "locate-path": "^6.0.0",
        "path-exists": "^4.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/flat-cache": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "flatted": "^3.2.9",
        "keyv": "^4.5.4"
      },
      "engines": {
        "node": ">=16"
      }
    },
    "node_modules/flatted": {
      "version": "3.3.3",
      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
      "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/for-each": {
      "version": "0.3.5",
      "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
      "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-callable": "^1.2.7"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/function-bind": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
      "dev": true,
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/function.prototype.name": {
      "version": "1.1.8",
      "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz",
      "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.3",
        "define-properties": "^1.2.1",
        "functions-have-names": "^1.2.3",
        "hasown": "^2.0.2",
        "is-callable": "^1.2.7"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/functions-have-names": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
      "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
      "dev": true,
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/generator-function": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
      "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/gensync": {
      "version": "1.0.0-beta.2",
      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/get-intrinsic": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind-apply-helpers": "^1.0.2",
        "es-define-property": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "function-bind": "^1.1.2",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "has-symbols": "^1.1.0",
        "hasown": "^2.0.2",
        "math-intrinsics": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-nonce": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
      "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/get-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "dunder-proto": "^1.0.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/get-symbol-description": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
      "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.6"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/get-tsconfig": {
      "version": "4.13.6",
      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
      "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "resolve-pkg-maps": "^1.0.0"
      },
      "funding": {
        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
      }
    },
    "node_modules/glob-parent": {
      "version": "6.0.2",
      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "is-glob": "^4.0.3"
      },
      "engines": {
        "node": ">=10.13.0"
      }
    },
    "node_modules/globals": {
      "version": "14.0.0",
      "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
      "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=18"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/globalthis": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
      "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "define-properties": "^1.2.1",
        "gopd": "^1.0.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/gopd": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/graceful-fs": {
      "version": "4.2.11",
      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/has-bigints": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
      "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-flag": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/has-property-descriptors": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
      "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-define-property": "^1.0.0"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-proto": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
      "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "dunder-proto": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-symbols": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/has-tostringtag": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "has-symbols": "^1.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/hasown": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "function-bind": "^1.1.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/hast-util-from-parse5": {
      "version": "8.0.3",
      "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
      "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "@types/unist": "^3.0.0",
        "devlop": "^1.0.0",
        "hastscript": "^9.0.0",
        "property-information": "^7.0.0",
        "vfile": "^6.0.0",
        "vfile-location": "^5.0.0",
        "web-namespaces": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/hast-util-is-element": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
      "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/hast-util-parse-selector": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
      "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/hast-util-raw": {
      "version": "9.1.0",
      "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
      "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "@types/unist": "^3.0.0",
        "@ungap/structured-clone": "^1.0.0",
        "hast-util-from-parse5": "^8.0.0",
        "hast-util-to-parse5": "^8.0.0",
        "html-void-elements": "^3.0.0",
        "mdast-util-to-hast": "^13.0.0",
        "parse5": "^7.0.0",
        "unist-util-position": "^5.0.0",
        "unist-util-visit": "^5.0.0",
        "vfile": "^6.0.0",
        "web-namespaces": "^2.0.0",
        "zwitch": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/hast-util-to-jsx-runtime": {
      "version": "2.3.6",
      "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
      "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
      "license": "MIT",
      "dependencies": {
        "@types/estree": "^1.0.0",
        "@types/hast": "^3.0.0",
        "@types/unist": "^3.0.0",
        "comma-separated-tokens": "^2.0.0",
        "devlop": "^1.0.0",
        "estree-util-is-identifier-name": "^3.0.0",
        "hast-util-whitespace": "^3.0.0",
        "mdast-util-mdx-expression": "^2.0.0",
        "mdast-util-mdx-jsx": "^3.0.0",
        "mdast-util-mdxjs-esm": "^2.0.0",
        "property-information": "^7.0.0",
        "space-separated-tokens": "^2.0.0",
        "style-to-js": "^1.0.0",
        "unist-util-position": "^5.0.0",
        "vfile-message": "^4.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/hast-util-to-parse5": {
      "version": "8.0.1",
      "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
      "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "comma-separated-tokens": "^2.0.0",
        "devlop": "^1.0.0",
        "property-information": "^7.0.0",
        "space-separated-tokens": "^2.0.0",
        "web-namespaces": "^2.0.0",
        "zwitch": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/hast-util-to-text": {
      "version": "4.0.2",
      "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
      "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "@types/unist": "^3.0.0",
        "hast-util-is-element": "^3.0.0",
        "unist-util-find-after": "^5.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/hast-util-whitespace": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
      "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/hastscript": {
      "version": "9.0.1",
      "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
      "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "comma-separated-tokens": "^2.0.0",
        "hast-util-parse-selector": "^4.0.0",
        "property-information": "^7.0.0",
        "space-separated-tokens": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/hermes-estree": {
      "version": "0.25.1",
      "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
      "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/hermes-parser": {
      "version": "0.25.1",
      "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
      "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "hermes-estree": "0.25.1"
      }
    },
    "node_modules/highlight.js": {
      "version": "11.11.1",
      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
      "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
      "license": "BSD-3-Clause",
      "engines": {
        "node": ">=12.0.0"
      }
    },
    "node_modules/html-url-attributes": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
      "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
      "license": "MIT",
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/html-void-elements": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
      "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/ignore": {
      "version": "5.3.2",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 4"
      }
    },
    "node_modules/import-fresh": {
      "version": "3.3.1",
      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
      "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "parent-module": "^1.0.0",
        "resolve-from": "^4.0.0"
      },
      "engines": {
        "node": ">=6"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/imurmurhash": {
      "version": "0.1.4",
      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.8.19"
      }
    },
    "node_modules/inline-style-parser": {
      "version": "0.2.7",
      "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
      "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
      "license": "MIT"
    },
    "node_modules/internal-slot": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
      "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "hasown": "^2.0.2",
        "side-channel": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/is-alphabetical": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
      "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/is-alphanumerical": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
      "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
      "license": "MIT",
      "dependencies": {
        "is-alphabetical": "^2.0.0",
        "is-decimal": "^2.0.0"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/is-array-buffer": {
      "version": "3.0.5",
      "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
      "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.3",
        "get-intrinsic": "^1.2.6"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-async-function": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
      "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "async-function": "^1.0.0",
        "call-bound": "^1.0.3",
        "get-proto": "^1.0.1",
        "has-tostringtag": "^1.0.2",
        "safe-regex-test": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-bigint": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
      "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "has-bigints": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-boolean-object": {
      "version": "1.2.2",
      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
      "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "has-tostringtag": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-bun-module": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
      "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "semver": "^7.7.1"
      }
    },
    "node_modules/is-bun-module/node_modules/semver": {
      "version": "7.7.4",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
      "dev": true,
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/is-callable": {
      "version": "1.2.7",
      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
      "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-core-module": {
      "version": "2.16.1",
      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "hasown": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-data-view": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
      "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "get-intrinsic": "^1.2.6",
        "is-typed-array": "^1.1.13"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-date-object": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
      "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "has-tostringtag": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-decimal": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
      "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/is-extglob": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/is-finalizationregistry": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
      "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-generator-function": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
      "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.4",
        "generator-function": "^2.0.0",
        "get-proto": "^1.0.1",
        "has-tostringtag": "^1.0.2",
        "safe-regex-test": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-glob": {
      "version": "4.0.3",
      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-extglob": "^2.1.1"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/is-hexadecimal": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
      "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/is-map": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
      "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-negative-zero": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
      "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-number": {
      "version": "7.0.0",
      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.12.0"
      }
    },
    "node_modules/is-number-object": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
      "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "has-tostringtag": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-plain-obj": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
      "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/is-regex": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
      "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "gopd": "^1.2.0",
        "has-tostringtag": "^1.0.2",
        "hasown": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-set": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
      "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-shared-array-buffer": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
      "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-string": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
      "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "has-tostringtag": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-symbol": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
      "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "has-symbols": "^1.1.0",
        "safe-regex-test": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-typed-array": {
      "version": "1.1.15",
      "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
      "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "which-typed-array": "^1.1.16"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-weakmap": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
      "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-weakref": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
      "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/is-weakset": {
      "version": "2.0.4",
      "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
      "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "get-intrinsic": "^1.2.6"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/isarray": {
      "version": "2.0.5",
      "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
      "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/isexe": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/iterator.prototype": {
      "version": "1.1.5",
      "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
      "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "define-data-property": "^1.1.4",
        "es-object-atoms": "^1.0.0",
        "get-intrinsic": "^1.2.6",
        "get-proto": "^1.0.0",
        "has-symbols": "^1.1.0",
        "set-function-name": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/jiti": {
      "version": "2.6.1",
      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
      "dev": true,
      "license": "MIT",
      "bin": {
        "jiti": "lib/jiti-cli.mjs"
      }
    },
    "node_modules/js-tokens": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/js-yaml": {
      "version": "4.1.1",
      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
      "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "argparse": "^2.0.1"
      },
      "bin": {
        "js-yaml": "bin/js-yaml.js"
      }
    },
    "node_modules/jsesc": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
      "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
      "dev": true,
      "license": "MIT",
      "bin": {
        "jsesc": "bin/jsesc"
      },
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/json-buffer": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/json-schema-traverse": {
      "version": "0.4.1",
      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/json-stable-stringify-without-jsonify": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/json5": {
      "version": "2.2.3",
      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
      "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
      "dev": true,
      "license": "MIT",
      "bin": {
        "json5": "lib/cli.js"
      },
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/jsx-ast-utils": {
      "version": "3.3.5",
      "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
      "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "array-includes": "^3.1.6",
        "array.prototype.flat": "^1.3.1",
        "object.assign": "^4.1.4",
        "object.values": "^1.1.6"
      },
      "engines": {
        "node": ">=4.0"
      }
    },
    "node_modules/keyv": {
      "version": "4.5.4",
      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "json-buffer": "3.0.1"
      }
    },
    "node_modules/language-subtag-registry": {
      "version": "0.3.23",
      "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
      "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==",
      "dev": true,
      "license": "CC0-1.0"
    },
    "node_modules/language-tags": {
      "version": "1.0.9",
      "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz",
      "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "language-subtag-registry": "^0.3.20"
      },
      "engines": {
        "node": ">=0.10"
      }
    },
    "node_modules/levn": {
      "version": "0.4.1",
      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "prelude-ls": "^1.2.1",
        "type-check": "~0.4.0"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/lightningcss": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
      "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
      "dev": true,
      "license": "MPL-2.0",
      "dependencies": {
        "detect-libc": "^2.0.3"
      },
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      },
      "optionalDependencies": {
        "lightningcss-android-arm64": "1.30.2",
        "lightningcss-darwin-arm64": "1.30.2",
        "lightningcss-darwin-x64": "1.30.2",
        "lightningcss-freebsd-x64": "1.30.2",
        "lightningcss-linux-arm-gnueabihf": "1.30.2",
        "lightningcss-linux-arm64-gnu": "1.30.2",
        "lightningcss-linux-arm64-musl": "1.30.2",
        "lightningcss-linux-x64-gnu": "1.30.2",
        "lightningcss-linux-x64-musl": "1.30.2",
        "lightningcss-win32-arm64-msvc": "1.30.2",
        "lightningcss-win32-x64-msvc": "1.30.2"
      }
    },
    "node_modules/lightningcss-android-arm64": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
      "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "android"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-darwin-arm64": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
      "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-darwin-x64": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
      "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-freebsd-x64": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
      "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "freebsd"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-arm-gnueabihf": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
      "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
      "cpu": [
        "arm"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-arm64-gnu": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
      "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-arm64-musl": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
      "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-x64-gnu": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
      "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-linux-x64-musl": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
      "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-win32-arm64-msvc": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
      "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
      "cpu": [
        "arm64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/lightningcss-win32-x64-msvc": {
      "version": "1.30.2",
      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
      "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
      "cpu": [
        "x64"
      ],
      "dev": true,
      "license": "MPL-2.0",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": ">= 12.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/parcel"
      }
    },
    "node_modules/locate-path": {
      "version": "6.0.0",
      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "p-locate": "^5.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/lodash.merge": {
      "version": "4.6.2",
      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/longest-streak": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
      "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/loose-envify": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "js-tokens": "^3.0.0 || ^4.0.0"
      },
      "bin": {
        "loose-envify": "cli.js"
      }
    },
    "node_modules/lowlight": {
      "version": "3.3.0",
      "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
      "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "devlop": "^1.0.0",
        "highlight.js": "~11.11.0"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/lru-cache": {
      "version": "5.1.1",
      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
      "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "yallist": "^3.0.2"
      }
    },
    "node_modules/lucide-react": {
      "version": "0.563.0",
      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz",
      "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==",
      "license": "ISC",
      "peerDependencies": {
        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
      }
    },
    "node_modules/magic-string": {
      "version": "0.30.21",
      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@jridgewell/sourcemap-codec": "^1.5.5"
      }
    },
    "node_modules/markdown-table": {
      "version": "3.0.4",
      "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
      "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/math-intrinsics": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/mdast-util-find-and-replace": {
      "version": "3.0.2",
      "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
      "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "escape-string-regexp": "^5.0.0",
        "unist-util-is": "^6.0.0",
        "unist-util-visit-parents": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
      "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/mdast-util-from-markdown": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
      "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "@types/unist": "^3.0.0",
        "decode-named-character-reference": "^1.0.0",
        "devlop": "^1.0.0",
        "mdast-util-to-string": "^4.0.0",
        "micromark": "^4.0.0",
        "micromark-util-decode-numeric-character-reference": "^2.0.0",
        "micromark-util-decode-string": "^2.0.0",
        "micromark-util-normalize-identifier": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0",
        "unist-util-stringify-position": "^4.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-gfm": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
      "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
      "license": "MIT",
      "dependencies": {
        "mdast-util-from-markdown": "^2.0.0",
        "mdast-util-gfm-autolink-literal": "^2.0.0",
        "mdast-util-gfm-footnote": "^2.0.0",
        "mdast-util-gfm-strikethrough": "^2.0.0",
        "mdast-util-gfm-table": "^2.0.0",
        "mdast-util-gfm-task-list-item": "^2.0.0",
        "mdast-util-to-markdown": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-gfm-autolink-literal": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
      "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "ccount": "^2.0.0",
        "devlop": "^1.0.0",
        "mdast-util-find-and-replace": "^3.0.0",
        "micromark-util-character": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-gfm-footnote": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
      "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "devlop": "^1.1.0",
        "mdast-util-from-markdown": "^2.0.0",
        "mdast-util-to-markdown": "^2.0.0",
        "micromark-util-normalize-identifier": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-gfm-strikethrough": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
      "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "mdast-util-from-markdown": "^2.0.0",
        "mdast-util-to-markdown": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-gfm-table": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
      "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "devlop": "^1.0.0",
        "markdown-table": "^3.0.0",
        "mdast-util-from-markdown": "^2.0.0",
        "mdast-util-to-markdown": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-gfm-task-list-item": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
      "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "devlop": "^1.0.0",
        "mdast-util-from-markdown": "^2.0.0",
        "mdast-util-to-markdown": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-mdx-expression": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
      "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
      "license": "MIT",
      "dependencies": {
        "@types/estree-jsx": "^1.0.0",
        "@types/hast": "^3.0.0",
        "@types/mdast": "^4.0.0",
        "devlop": "^1.0.0",
        "mdast-util-from-markdown": "^2.0.0",
        "mdast-util-to-markdown": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-mdx-jsx": {
      "version": "3.2.0",
      "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
      "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
      "license": "MIT",
      "dependencies": {
        "@types/estree-jsx": "^1.0.0",
        "@types/hast": "^3.0.0",
        "@types/mdast": "^4.0.0",
        "@types/unist": "^3.0.0",
        "ccount": "^2.0.0",
        "devlop": "^1.1.0",
        "mdast-util-from-markdown": "^2.0.0",
        "mdast-util-to-markdown": "^2.0.0",
        "parse-entities": "^4.0.0",
        "stringify-entities": "^4.0.0",
        "unist-util-stringify-position": "^4.0.0",
        "vfile-message": "^4.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-mdxjs-esm": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
      "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
      "license": "MIT",
      "dependencies": {
        "@types/estree-jsx": "^1.0.0",
        "@types/hast": "^3.0.0",
        "@types/mdast": "^4.0.0",
        "devlop": "^1.0.0",
        "mdast-util-from-markdown": "^2.0.0",
        "mdast-util-to-markdown": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-phrasing": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
      "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "unist-util-is": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-to-hast": {
      "version": "13.2.1",
      "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
      "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "@types/mdast": "^4.0.0",
        "@ungap/structured-clone": "^1.0.0",
        "devlop": "^1.0.0",
        "micromark-util-sanitize-uri": "^2.0.0",
        "trim-lines": "^3.0.0",
        "unist-util-position": "^5.0.0",
        "unist-util-visit": "^5.0.0",
        "vfile": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-to-markdown": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
      "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "@types/unist": "^3.0.0",
        "longest-streak": "^3.0.0",
        "mdast-util-phrasing": "^4.0.0",
        "mdast-util-to-string": "^4.0.0",
        "micromark-util-classify-character": "^2.0.0",
        "micromark-util-decode-string": "^2.0.0",
        "unist-util-visit": "^5.0.0",
        "zwitch": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/mdast-util-to-string": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
      "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/merge2": {
      "version": "1.4.1",
      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/micromark": {
      "version": "4.0.2",
      "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
      "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "@types/debug": "^4.0.0",
        "debug": "^4.0.0",
        "decode-named-character-reference": "^1.0.0",
        "devlop": "^1.0.0",
        "micromark-core-commonmark": "^2.0.0",
        "micromark-factory-space": "^2.0.0",
        "micromark-util-character": "^2.0.0",
        "micromark-util-chunked": "^2.0.0",
        "micromark-util-combine-extensions": "^2.0.0",
        "micromark-util-decode-numeric-character-reference": "^2.0.0",
        "micromark-util-encode": "^2.0.0",
        "micromark-util-normalize-identifier": "^2.0.0",
        "micromark-util-resolve-all": "^2.0.0",
        "micromark-util-sanitize-uri": "^2.0.0",
        "micromark-util-subtokenize": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-core-commonmark": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
      "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "decode-named-character-reference": "^1.0.0",
        "devlop": "^1.0.0",
        "micromark-factory-destination": "^2.0.0",
        "micromark-factory-label": "^2.0.0",
        "micromark-factory-space": "^2.0.0",
        "micromark-factory-title": "^2.0.0",
        "micromark-factory-whitespace": "^2.0.0",
        "micromark-util-character": "^2.0.0",
        "micromark-util-chunked": "^2.0.0",
        "micromark-util-classify-character": "^2.0.0",
        "micromark-util-html-tag-name": "^2.0.0",
        "micromark-util-normalize-identifier": "^2.0.0",
        "micromark-util-resolve-all": "^2.0.0",
        "micromark-util-subtokenize": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-extension-gfm": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
      "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
      "license": "MIT",
      "dependencies": {
        "micromark-extension-gfm-autolink-literal": "^2.0.0",
        "micromark-extension-gfm-footnote": "^2.0.0",
        "micromark-extension-gfm-strikethrough": "^2.0.0",
        "micromark-extension-gfm-table": "^2.0.0",
        "micromark-extension-gfm-tagfilter": "^2.0.0",
        "micromark-extension-gfm-task-list-item": "^2.0.0",
        "micromark-util-combine-extensions": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/micromark-extension-gfm-autolink-literal": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
      "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
      "license": "MIT",
      "dependencies": {
        "micromark-util-character": "^2.0.0",
        "micromark-util-sanitize-uri": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/micromark-extension-gfm-footnote": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
      "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
      "license": "MIT",
      "dependencies": {
        "devlop": "^1.0.0",
        "micromark-core-commonmark": "^2.0.0",
        "micromark-factory-space": "^2.0.0",
        "micromark-util-character": "^2.0.0",
        "micromark-util-normalize-identifier": "^2.0.0",
        "micromark-util-sanitize-uri": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/micromark-extension-gfm-strikethrough": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
      "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
      "license": "MIT",
      "dependencies": {
        "devlop": "^1.0.0",
        "micromark-util-chunked": "^2.0.0",
        "micromark-util-classify-character": "^2.0.0",
        "micromark-util-resolve-all": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/micromark-extension-gfm-table": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
      "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
      "license": "MIT",
      "dependencies": {
        "devlop": "^1.0.0",
        "micromark-factory-space": "^2.0.0",
        "micromark-util-character": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/micromark-extension-gfm-tagfilter": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
      "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
      "license": "MIT",
      "dependencies": {
        "micromark-util-types": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/micromark-extension-gfm-task-list-item": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
      "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
      "license": "MIT",
      "dependencies": {
        "devlop": "^1.0.0",
        "micromark-factory-space": "^2.0.0",
        "micromark-util-character": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/micromark-factory-destination": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
      "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-character": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-factory-label": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
      "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "devlop": "^1.0.0",
        "micromark-util-character": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-factory-space": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
      "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-character": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-factory-title": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
      "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-factory-space": "^2.0.0",
        "micromark-util-character": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-factory-whitespace": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
      "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-factory-space": "^2.0.0",
        "micromark-util-character": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-util-character": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
      "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-util-chunked": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
      "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-symbol": "^2.0.0"
      }
    },
    "node_modules/micromark-util-classify-character": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
      "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-character": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-util-combine-extensions": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
      "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-chunked": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-util-decode-numeric-character-reference": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
      "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-symbol": "^2.0.0"
      }
    },
    "node_modules/micromark-util-decode-string": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
      "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "decode-named-character-reference": "^1.0.0",
        "micromark-util-character": "^2.0.0",
        "micromark-util-decode-numeric-character-reference": "^2.0.0",
        "micromark-util-symbol": "^2.0.0"
      }
    },
    "node_modules/micromark-util-encode": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
      "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT"
    },
    "node_modules/micromark-util-html-tag-name": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
      "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT"
    },
    "node_modules/micromark-util-normalize-identifier": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
      "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-symbol": "^2.0.0"
      }
    },
    "node_modules/micromark-util-resolve-all": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
      "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-util-sanitize-uri": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
      "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "micromark-util-character": "^2.0.0",
        "micromark-util-encode": "^2.0.0",
        "micromark-util-symbol": "^2.0.0"
      }
    },
    "node_modules/micromark-util-subtokenize": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
      "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "devlop": "^1.0.0",
        "micromark-util-chunked": "^2.0.0",
        "micromark-util-symbol": "^2.0.0",
        "micromark-util-types": "^2.0.0"
      }
    },
    "node_modules/micromark-util-symbol": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
      "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT"
    },
    "node_modules/micromark-util-types": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
      "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
      "funding": [
        {
          "type": "GitHub Sponsors",
          "url": "https://github.com/sponsors/unifiedjs"
        },
        {
          "type": "OpenCollective",
          "url": "https://opencollective.com/unified"
        }
      ],
      "license": "MIT"
    },
    "node_modules/micromatch": {
      "version": "4.0.8",
      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "braces": "^3.0.3",
        "picomatch": "^2.3.1"
      },
      "engines": {
        "node": ">=8.6"
      }
    },
    "node_modules/minimatch": {
      "version": "3.1.2",
      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "brace-expansion": "^1.1.7"
      },
      "engines": {
        "node": "*"
      }
    },
    "node_modules/minimist": {
      "version": "1.2.8",
      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
      "dev": true,
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
      "license": "MIT"
    },
    "node_modules/nanoid": {
      "version": "3.3.11",
      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "bin": {
        "nanoid": "bin/nanoid.cjs"
      },
      "engines": {
        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
      }
    },
    "node_modules/napi-postinstall": {
      "version": "0.3.4",
      "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
      "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==",
      "dev": true,
      "license": "MIT",
      "bin": {
        "napi-postinstall": "lib/cli.js"
      },
      "engines": {
        "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/napi-postinstall"
      }
    },
    "node_modules/natural-compare": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/next": {
      "version": "16.1.6",
      "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
      "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
      "license": "MIT",
      "dependencies": {
        "@next/env": "16.1.6",
        "@swc/helpers": "0.5.15",
        "baseline-browser-mapping": "^2.8.3",
        "caniuse-lite": "^1.0.30001579",
        "postcss": "8.4.31",
        "styled-jsx": "5.1.6"
      },
      "bin": {
        "next": "dist/bin/next"
      },
      "engines": {
        "node": ">=20.9.0"
      },
      "optionalDependencies": {
        "@next/swc-darwin-arm64": "16.1.6",
        "@next/swc-darwin-x64": "16.1.6",
        "@next/swc-linux-arm64-gnu": "16.1.6",
        "@next/swc-linux-arm64-musl": "16.1.6",
        "@next/swc-linux-x64-gnu": "16.1.6",
        "@next/swc-linux-x64-musl": "16.1.6",
        "@next/swc-win32-arm64-msvc": "16.1.6",
        "@next/swc-win32-x64-msvc": "16.1.6",
        "sharp": "^0.34.4"
      },
      "peerDependencies": {
        "@opentelemetry/api": "^1.1.0",
        "@playwright/test": "^1.51.1",
        "babel-plugin-react-compiler": "*",
        "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
        "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
        "sass": "^1.3.0"
      },
      "peerDependenciesMeta": {
        "@opentelemetry/api": {
          "optional": true
        },
        "@playwright/test": {
          "optional": true
        },
        "babel-plugin-react-compiler": {
          "optional": true
        },
        "sass": {
          "optional": true
        }
      }
    },
    "node_modules/next/node_modules/postcss": {
      "version": "8.4.31",
      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
      "funding": [
        {
          "type": "opencollective",
          "url": "https://opencollective.com/postcss/"
        },
        {
          "type": "tidelift",
          "url": "https://tidelift.com/funding/github/npm/postcss"
        },
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "nanoid": "^3.3.6",
        "picocolors": "^1.0.0",
        "source-map-js": "^1.0.2"
      },
      "engines": {
        "node": "^10 || ^12 || >=14"
      }
    },
    "node_modules/node-releases": {
      "version": "2.0.27",
      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
      "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/object-assign": {
      "version": "4.1.1",
      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/object-inspect": {
      "version": "1.13.4",
      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/object-keys": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/object.assign": {
      "version": "4.1.7",
      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
      "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.3",
        "define-properties": "^1.2.1",
        "es-object-atoms": "^1.0.0",
        "has-symbols": "^1.1.0",
        "object-keys": "^1.1.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/object.entries": {
      "version": "1.1.9",
      "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
      "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.4",
        "define-properties": "^1.2.1",
        "es-object-atoms": "^1.1.1"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/object.fromentries": {
      "version": "2.0.8",
      "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
      "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.7",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.2",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/object.groupby": {
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz",
      "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.7",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/object.values": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
      "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.3",
        "define-properties": "^1.2.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/optionator": {
      "version": "0.9.4",
      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "deep-is": "^0.1.3",
        "fast-levenshtein": "^2.0.6",
        "levn": "^0.4.1",
        "prelude-ls": "^1.2.1",
        "type-check": "^0.4.0",
        "word-wrap": "^1.2.5"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/own-keys": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
      "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "get-intrinsic": "^1.2.6",
        "object-keys": "^1.1.1",
        "safe-push-apply": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/p-limit": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "yocto-queue": "^0.1.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/p-locate": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "p-limit": "^3.0.2"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/parent-module": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "callsites": "^3.0.0"
      },
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/parse-entities": {
      "version": "4.0.2",
      "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
      "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^2.0.0",
        "character-entities-legacy": "^3.0.0",
        "character-reference-invalid": "^2.0.0",
        "decode-named-character-reference": "^1.0.0",
        "is-alphanumerical": "^2.0.0",
        "is-decimal": "^2.0.0",
        "is-hexadecimal": "^2.0.0"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/parse-entities/node_modules/@types/unist": {
      "version": "2.0.11",
      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
      "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
      "license": "MIT"
    },
    "node_modules/parse5": {
      "version": "7.3.0",
      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
      "license": "MIT",
      "dependencies": {
        "entities": "^6.0.0"
      },
      "funding": {
        "url": "https://github.com/inikulin/parse5?sponsor=1"
      }
    },
    "node_modules/path-exists": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/path-key": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/path-parse": {
      "version": "1.0.7",
      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/picocolors": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
      "license": "ISC"
    },
    "node_modules/picomatch": {
      "version": "2.3.1",
      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8.6"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/possible-typed-array-names": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
      "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/postcss": {
      "version": "8.5.6",
      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
      "dev": true,
      "funding": [
        {
          "type": "opencollective",
          "url": "https://opencollective.com/postcss/"
        },
        {
          "type": "tidelift",
          "url": "https://tidelift.com/funding/github/npm/postcss"
        },
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "nanoid": "^3.3.11",
        "picocolors": "^1.1.1",
        "source-map-js": "^1.2.1"
      },
      "engines": {
        "node": "^10 || ^12 || >=14"
      }
    },
    "node_modules/postcss-selector-parser": {
      "version": "6.0.10",
      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
      "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "cssesc": "^3.0.0",
        "util-deprecate": "^1.0.2"
      },
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/prelude-ls": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/prop-types": {
      "version": "15.8.1",
      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "loose-envify": "^1.4.0",
        "object-assign": "^4.1.1",
        "react-is": "^16.13.1"
      }
    },
    "node_modules/property-information": {
      "version": "7.1.0",
      "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
      "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/punycode": {
      "version": "2.3.1",
      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/queue-microtask": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
      "dev": true,
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT"
    },
    "node_modules/react": {
      "version": "19.2.3",
      "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
      "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/react-dom": {
      "version": "19.2.3",
      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
      "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
      "license": "MIT",
      "dependencies": {
        "scheduler": "^0.27.0"
      },
      "peerDependencies": {
        "react": "^19.2.3"
      }
    },
    "node_modules/react-is": {
      "version": "16.13.1",
      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/react-markdown": {
      "version": "10.1.0",
      "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
      "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "@types/mdast": "^4.0.0",
        "devlop": "^1.0.0",
        "hast-util-to-jsx-runtime": "^2.0.0",
        "html-url-attributes": "^3.0.0",
        "mdast-util-to-hast": "^13.0.0",
        "remark-parse": "^11.0.0",
        "remark-rehype": "^11.0.0",
        "unified": "^11.0.0",
        "unist-util-visit": "^5.0.0",
        "vfile": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      },
      "peerDependencies": {
        "@types/react": ">=18",
        "react": ">=18"
      }
    },
    "node_modules/react-remove-scroll": {
      "version": "2.7.2",
      "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
      "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
      "license": "MIT",
      "dependencies": {
        "react-remove-scroll-bar": "^2.3.7",
        "react-style-singleton": "^2.2.3",
        "tslib": "^2.1.0",
        "use-callback-ref": "^1.3.3",
        "use-sidecar": "^1.1.3"
      },
      "engines": {
        "node": ">=10"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/react-remove-scroll-bar": {
      "version": "2.3.8",
      "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
      "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
      "license": "MIT",
      "dependencies": {
        "react-style-singleton": "^2.2.2",
        "tslib": "^2.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/react-style-singleton": {
      "version": "2.2.3",
      "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
      "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
      "license": "MIT",
      "dependencies": {
        "get-nonce": "^1.0.0",
        "tslib": "^2.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/reflect.getprototypeof": {
      "version": "1.0.10",
      "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
      "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.9",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.0.0",
        "get-intrinsic": "^1.2.7",
        "get-proto": "^1.0.1",
        "which-builtin-type": "^1.2.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/regexp.prototype.flags": {
      "version": "1.5.4",
      "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
      "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "define-properties": "^1.2.1",
        "es-errors": "^1.3.0",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "set-function-name": "^2.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/rehype-highlight": {
      "version": "7.0.2",
      "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz",
      "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "hast-util-to-text": "^4.0.0",
        "lowlight": "^3.0.0",
        "unist-util-visit": "^5.0.0",
        "vfile": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/rehype-raw": {
      "version": "7.0.0",
      "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
      "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "hast-util-raw": "^9.0.0",
        "vfile": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/remark-gfm": {
      "version": "4.0.1",
      "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
      "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "mdast-util-gfm": "^3.0.0",
        "micromark-extension-gfm": "^3.0.0",
        "remark-parse": "^11.0.0",
        "remark-stringify": "^11.0.0",
        "unified": "^11.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/remark-parse": {
      "version": "11.0.0",
      "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
      "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "mdast-util-from-markdown": "^2.0.0",
        "micromark-util-types": "^2.0.0",
        "unified": "^11.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/remark-rehype": {
      "version": "11.1.2",
      "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
      "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
      "license": "MIT",
      "dependencies": {
        "@types/hast": "^3.0.0",
        "@types/mdast": "^4.0.0",
        "mdast-util-to-hast": "^13.0.0",
        "unified": "^11.0.0",
        "vfile": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/remark-stringify": {
      "version": "11.0.0",
      "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
      "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
      "license": "MIT",
      "dependencies": {
        "@types/mdast": "^4.0.0",
        "mdast-util-to-markdown": "^2.0.0",
        "unified": "^11.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/resolve": {
      "version": "1.22.11",
      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
      "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-core-module": "^2.16.1",
        "path-parse": "^1.0.7",
        "supports-preserve-symlinks-flag": "^1.0.0"
      },
      "bin": {
        "resolve": "bin/resolve"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/resolve-from": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/resolve-pkg-maps": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
      "dev": true,
      "license": "MIT",
      "funding": {
        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
      }
    },
    "node_modules/reusify": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "iojs": ">=1.0.0",
        "node": ">=0.10.0"
      }
    },
    "node_modules/run-parallel": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
      "dev": true,
      "funding": [
        {
          "type": "github",
          "url": "https://github.com/sponsors/feross"
        },
        {
          "type": "patreon",
          "url": "https://www.patreon.com/feross"
        },
        {
          "type": "consulting",
          "url": "https://feross.org/support"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "queue-microtask": "^1.2.2"
      }
    },
    "node_modules/safe-array-concat": {
      "version": "1.1.3",
      "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
      "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.2",
        "get-intrinsic": "^1.2.6",
        "has-symbols": "^1.1.0",
        "isarray": "^2.0.5"
      },
      "engines": {
        "node": ">=0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/safe-push-apply": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
      "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "isarray": "^2.0.5"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/safe-regex-test": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
      "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "es-errors": "^1.3.0",
        "is-regex": "^1.2.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/scheduler": {
      "version": "0.27.0",
      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
      "license": "MIT"
    },
    "node_modules/semver": {
      "version": "6.3.1",
      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
      "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
      "dev": true,
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      }
    },
    "node_modules/set-function-length": {
      "version": "1.2.2",
      "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
      "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "define-data-property": "^1.1.4",
        "es-errors": "^1.3.0",
        "function-bind": "^1.1.2",
        "get-intrinsic": "^1.2.4",
        "gopd": "^1.0.1",
        "has-property-descriptors": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/set-function-name": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
      "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "define-data-property": "^1.1.4",
        "es-errors": "^1.3.0",
        "functions-have-names": "^1.2.3",
        "has-property-descriptors": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/set-proto": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
      "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "dunder-proto": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/sharp": {
      "version": "0.34.5",
      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
      "hasInstallScript": true,
      "license": "Apache-2.0",
      "optional": true,
      "dependencies": {
        "@img/colour": "^1.0.0",
        "detect-libc": "^2.1.2",
        "semver": "^7.7.3"
      },
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-darwin-arm64": "0.34.5",
        "@img/sharp-darwin-x64": "0.34.5",
        "@img/sharp-libvips-darwin-arm64": "1.2.4",
        "@img/sharp-libvips-darwin-x64": "1.2.4",
        "@img/sharp-libvips-linux-arm": "1.2.4",
        "@img/sharp-libvips-linux-arm64": "1.2.4",
        "@img/sharp-libvips-linux-ppc64": "1.2.4",
        "@img/sharp-libvips-linux-riscv64": "1.2.4",
        "@img/sharp-libvips-linux-s390x": "1.2.4",
        "@img/sharp-libvips-linux-x64": "1.2.4",
        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
        "@img/sharp-linux-arm": "0.34.5",
        "@img/sharp-linux-arm64": "0.34.5",
        "@img/sharp-linux-ppc64": "0.34.5",
        "@img/sharp-linux-riscv64": "0.34.5",
        "@img/sharp-linux-s390x": "0.34.5",
        "@img/sharp-linux-x64": "0.34.5",
        "@img/sharp-linuxmusl-arm64": "0.34.5",
        "@img/sharp-linuxmusl-x64": "0.34.5",
        "@img/sharp-wasm32": "0.34.5",
        "@img/sharp-win32-arm64": "0.34.5",
        "@img/sharp-win32-ia32": "0.34.5",
        "@img/sharp-win32-x64": "0.34.5"
      }
    },
    "node_modules/sharp/node_modules/semver": {
      "version": "7.7.4",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
      "license": "ISC",
      "optional": true,
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/shebang-command": {
      "version": "2.0.0",
      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "shebang-regex": "^3.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/shebang-regex": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/side-channel": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "object-inspect": "^1.13.3",
        "side-channel-list": "^1.0.0",
        "side-channel-map": "^1.0.1",
        "side-channel-weakmap": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/side-channel-list": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "object-inspect": "^1.13.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/side-channel-map": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.5",
        "object-inspect": "^1.13.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/side-channel-weakmap": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.5",
        "object-inspect": "^1.13.3",
        "side-channel-map": "^1.0.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/sonner": {
      "version": "2.0.7",
      "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
      "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
      "license": "MIT",
      "peerDependencies": {
        "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
        "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
      }
    },
    "node_modules/source-map-js": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
      "license": "BSD-3-Clause",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/space-separated-tokens": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
      "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/stable-hash": {
      "version": "0.0.5",
      "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
      "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/stop-iteration-iterator": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
      "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "es-errors": "^1.3.0",
        "internal-slot": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/string.prototype.includes": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
      "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.7",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.3"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/string.prototype.matchall": {
      "version": "4.0.12",
      "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
      "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.3",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.6",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.0.0",
        "get-intrinsic": "^1.2.6",
        "gopd": "^1.2.0",
        "has-symbols": "^1.1.0",
        "internal-slot": "^1.1.0",
        "regexp.prototype.flags": "^1.5.3",
        "set-function-name": "^2.0.2",
        "side-channel": "^1.1.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/string.prototype.repeat": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
      "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "define-properties": "^1.1.3",
        "es-abstract": "^1.17.5"
      }
    },
    "node_modules/string.prototype.trim": {
      "version": "1.2.10",
      "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
      "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.2",
        "define-data-property": "^1.1.4",
        "define-properties": "^1.2.1",
        "es-abstract": "^1.23.5",
        "es-object-atoms": "^1.0.0",
        "has-property-descriptors": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/string.prototype.trimend": {
      "version": "1.0.9",
      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz",
      "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.2",
        "define-properties": "^1.2.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/string.prototype.trimstart": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
      "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.7",
        "define-properties": "^1.2.1",
        "es-object-atoms": "^1.0.0"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/stringify-entities": {
      "version": "4.0.4",
      "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
      "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
      "license": "MIT",
      "dependencies": {
        "character-entities-html4": "^2.0.0",
        "character-entities-legacy": "^3.0.0"
      },
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/strip-bom": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
      "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=4"
      }
    },
    "node_modules/strip-json-comments": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/style-to-js": {
      "version": "1.1.21",
      "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
      "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
      "license": "MIT",
      "dependencies": {
        "style-to-object": "1.0.14"
      }
    },
    "node_modules/style-to-object": {
      "version": "1.0.14",
      "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
      "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
      "license": "MIT",
      "dependencies": {
        "inline-style-parser": "0.2.7"
      }
    },
    "node_modules/styled-jsx": {
      "version": "5.1.6",
      "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
      "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
      "license": "MIT",
      "dependencies": {
        "client-only": "0.0.1"
      },
      "engines": {
        "node": ">= 12.0.0"
      },
      "peerDependencies": {
        "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
      },
      "peerDependenciesMeta": {
        "@babel/core": {
          "optional": true
        },
        "babel-plugin-macros": {
          "optional": true
        }
      }
    },
    "node_modules/supports-color": {
      "version": "7.2.0",
      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "has-flag": "^4.0.0"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/supports-preserve-symlinks-flag": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/tailwind-merge": {
      "version": "3.4.0",
      "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
      "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/dcastil"
      }
    },
    "node_modules/tailwindcss": {
      "version": "4.1.18",
      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
      "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/tapable": {
      "version": "2.3.0",
      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
      "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=6"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/webpack"
      }
    },
    "node_modules/tinyglobby": {
      "version": "0.2.15",
      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "fdir": "^6.5.0",
        "picomatch": "^4.0.3"
      },
      "engines": {
        "node": ">=12.0.0"
      },
      "funding": {
        "url": "https://github.com/sponsors/SuperchupuDev"
      }
    },
    "node_modules/tinyglobby/node_modules/fdir": {
      "version": "6.5.0",
      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12.0.0"
      },
      "peerDependencies": {
        "picomatch": "^3 || ^4"
      },
      "peerDependenciesMeta": {
        "picomatch": {
          "optional": true
        }
      }
    },
    "node_modules/tinyglobby/node_modules/picomatch": {
      "version": "4.0.3",
      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=12"
      },
      "funding": {
        "url": "https://github.com/sponsors/jonschlinkert"
      }
    },
    "node_modules/to-regex-range": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-number": "^7.0.0"
      },
      "engines": {
        "node": ">=8.0"
      }
    },
    "node_modules/trim-lines": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
      "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/trough": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
      "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/ts-api-utils": {
      "version": "2.4.0",
      "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
      "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=18.12"
      },
      "peerDependencies": {
        "typescript": ">=4.8.4"
      }
    },
    "node_modules/tsconfig-paths": {
      "version": "3.15.0",
      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
      "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@types/json5": "^0.0.29",
        "json5": "^1.0.2",
        "minimist": "^1.2.6",
        "strip-bom": "^3.0.0"
      }
    },
    "node_modules/tsconfig-paths/node_modules/json5": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
      "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "minimist": "^1.2.0"
      },
      "bin": {
        "json5": "lib/cli.js"
      }
    },
    "node_modules/tslib": {
      "version": "2.8.1",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
      "license": "0BSD"
    },
    "node_modules/type-check": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "prelude-ls": "^1.2.1"
      },
      "engines": {
        "node": ">= 0.8.0"
      }
    },
    "node_modules/typed-array-buffer": {
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
      "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "es-errors": "^1.3.0",
        "is-typed-array": "^1.1.14"
      },
      "engines": {
        "node": ">= 0.4"
      }
    },
    "node_modules/typed-array-byte-length": {
      "version": "1.0.3",
      "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
      "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.8",
        "for-each": "^0.3.3",
        "gopd": "^1.2.0",
        "has-proto": "^1.2.0",
        "is-typed-array": "^1.1.14"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/typed-array-byte-offset": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz",
      "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "available-typed-arrays": "^1.0.7",
        "call-bind": "^1.0.8",
        "for-each": "^0.3.3",
        "gopd": "^1.2.0",
        "has-proto": "^1.2.0",
        "is-typed-array": "^1.1.15",
        "reflect.getprototypeof": "^1.0.9"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/typed-array-length": {
      "version": "1.0.7",
      "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz",
      "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bind": "^1.0.7",
        "for-each": "^0.3.3",
        "gopd": "^1.0.1",
        "is-typed-array": "^1.1.13",
        "possible-typed-array-names": "^1.0.0",
        "reflect.getprototypeof": "^1.0.6"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/typescript": {
      "version": "5.9.3",
      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
      "dev": true,
      "license": "Apache-2.0",
      "bin": {
        "tsc": "bin/tsc",
        "tsserver": "bin/tsserver"
      },
      "engines": {
        "node": ">=14.17"
      }
    },
    "node_modules/typescript-eslint": {
      "version": "8.55.0",
      "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz",
      "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "@typescript-eslint/eslint-plugin": "8.55.0",
        "@typescript-eslint/parser": "8.55.0",
        "@typescript-eslint/typescript-estree": "8.55.0",
        "@typescript-eslint/utils": "8.55.0"
      },
      "engines": {
        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/typescript-eslint"
      },
      "peerDependencies": {
        "eslint": "^8.57.0 || ^9.0.0",
        "typescript": ">=4.8.4 <6.0.0"
      }
    },
    "node_modules/unbox-primitive": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
      "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.3",
        "has-bigints": "^1.0.2",
        "has-symbols": "^1.1.0",
        "which-boxed-primitive": "^1.1.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/undici-types": {
      "version": "6.21.0",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/unified": {
      "version": "11.0.5",
      "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
      "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0",
        "bail": "^2.0.0",
        "devlop": "^1.0.0",
        "extend": "^3.0.0",
        "is-plain-obj": "^4.0.0",
        "trough": "^2.0.0",
        "vfile": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/unist-util-find-after": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
      "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0",
        "unist-util-is": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/unist-util-is": {
      "version": "6.0.1",
      "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
      "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/unist-util-position": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
      "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/unist-util-stringify-position": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
      "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/unist-util-visit": {
      "version": "5.1.0",
      "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
      "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0",
        "unist-util-is": "^6.0.0",
        "unist-util-visit-parents": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/unist-util-visit-parents": {
      "version": "6.0.2",
      "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
      "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0",
        "unist-util-is": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/unrs-resolver": {
      "version": "1.11.1",
      "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
      "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==",
      "dev": true,
      "hasInstallScript": true,
      "license": "MIT",
      "dependencies": {
        "napi-postinstall": "^0.3.0"
      },
      "funding": {
        "url": "https://opencollective.com/unrs-resolver"
      },
      "optionalDependencies": {
        "@unrs/resolver-binding-android-arm-eabi": "1.11.1",
        "@unrs/resolver-binding-android-arm64": "1.11.1",
        "@unrs/resolver-binding-darwin-arm64": "1.11.1",
        "@unrs/resolver-binding-darwin-x64": "1.11.1",
        "@unrs/resolver-binding-freebsd-x64": "1.11.1",
        "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1",
        "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1",
        "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1",
        "@unrs/resolver-binding-linux-arm64-musl": "1.11.1",
        "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1",
        "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1",
        "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1",
        "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1",
        "@unrs/resolver-binding-linux-x64-gnu": "1.11.1",
        "@unrs/resolver-binding-linux-x64-musl": "1.11.1",
        "@unrs/resolver-binding-wasm32-wasi": "1.11.1",
        "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1",
        "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1",
        "@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
      }
    },
    "node_modules/update-browserslist-db": {
      "version": "1.2.3",
      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
      "dev": true,
      "funding": [
        {
          "type": "opencollective",
          "url": "https://opencollective.com/browserslist"
        },
        {
          "type": "tidelift",
          "url": "https://tidelift.com/funding/github/npm/browserslist"
        },
        {
          "type": "github",
          "url": "https://github.com/sponsors/ai"
        }
      ],
      "license": "MIT",
      "dependencies": {
        "escalade": "^3.2.0",
        "picocolors": "^1.1.1"
      },
      "bin": {
        "update-browserslist-db": "cli.js"
      },
      "peerDependencies": {
        "browserslist": ">= 4.21.0"
      }
    },
    "node_modules/uri-js": {
      "version": "4.4.1",
      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
      "dev": true,
      "license": "BSD-2-Clause",
      "dependencies": {
        "punycode": "^2.1.0"
      }
    },
    "node_modules/use-callback-ref": {
      "version": "1.3.3",
      "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
      "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
      "license": "MIT",
      "dependencies": {
        "tslib": "^2.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/use-sidecar": {
      "version": "1.1.3",
      "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
      "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
      "license": "MIT",
      "dependencies": {
        "detect-node-es": "^1.1.0",
        "tslib": "^2.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "peerDependencies": {
        "@types/react": "*",
        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        }
      }
    },
    "node_modules/util-deprecate": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
      "dev": true,
      "license": "MIT"
    },
    "node_modules/vfile": {
      "version": "6.0.3",
      "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
      "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0",
        "vfile-message": "^4.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/vfile-location": {
      "version": "5.0.3",
      "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
      "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0",
        "vfile": "^6.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/vfile-message": {
      "version": "4.0.3",
      "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
      "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
      "license": "MIT",
      "dependencies": {
        "@types/unist": "^3.0.0",
        "unist-util-stringify-position": "^4.0.0"
      },
      "funding": {
        "type": "opencollective",
        "url": "https://opencollective.com/unified"
      }
    },
    "node_modules/web-namespaces": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
      "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    },
    "node_modules/which": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
      "dev": true,
      "license": "ISC",
      "dependencies": {
        "isexe": "^2.0.0"
      },
      "bin": {
        "node-which": "bin/node-which"
      },
      "engines": {
        "node": ">= 8"
      }
    },
    "node_modules/which-boxed-primitive": {
      "version": "1.1.1",
      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
      "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-bigint": "^1.1.0",
        "is-boolean-object": "^1.2.1",
        "is-number-object": "^1.1.1",
        "is-string": "^1.1.1",
        "is-symbol": "^1.1.1"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/which-builtin-type": {
      "version": "1.2.1",
      "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
      "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "call-bound": "^1.0.2",
        "function.prototype.name": "^1.1.6",
        "has-tostringtag": "^1.0.2",
        "is-async-function": "^2.0.0",
        "is-date-object": "^1.1.0",
        "is-finalizationregistry": "^1.1.0",
        "is-generator-function": "^1.0.10",
        "is-regex": "^1.2.1",
        "is-weakref": "^1.0.2",
        "isarray": "^2.0.5",
        "which-boxed-primitive": "^1.1.0",
        "which-collection": "^1.0.2",
        "which-typed-array": "^1.1.16"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/which-collection": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
      "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "is-map": "^2.0.3",
        "is-set": "^2.0.3",
        "is-weakmap": "^2.0.2",
        "is-weakset": "^2.0.3"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/which-typed-array": {
      "version": "1.1.20",
      "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
      "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
      "dev": true,
      "license": "MIT",
      "dependencies": {
        "available-typed-arrays": "^1.0.7",
        "call-bind": "^1.0.8",
        "call-bound": "^1.0.4",
        "for-each": "^0.3.5",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "has-tostringtag": "^1.0.2"
      },
      "engines": {
        "node": ">= 0.4"
      },
      "funding": {
        "url": "https://github.com/sponsors/ljharb"
      }
    },
    "node_modules/word-wrap": {
      "version": "1.2.5",
      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/yallist": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
      "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
      "dev": true,
      "license": "ISC"
    },
    "node_modules/yocto-queue": {
      "version": "0.1.0",
      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
    "node_modules/zod": {
      "version": "4.3.6",
      "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
      "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/colinhacks"
      }
    },
    "node_modules/zod-validation-error": {
      "version": "4.0.2",
      "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
      "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
      "dev": true,
      "license": "MIT",
      "engines": {
        "node": ">=18.0.0"
      },
      "peerDependencies": {
        "zod": "^3.25.0 || ^4.0.0"
      }
    },
    "node_modules/zustand": {
      "version": "5.0.11",
      "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
      "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
      "license": "MIT",
      "engines": {
        "node": ">=12.20.0"
      },
      "peerDependencies": {
        "@types/react": ">=18.0.0",
        "immer": ">=9.0.6",
        "react": ">=18.0.0",
        "use-sync-external-store": ">=1.2.0"
      },
      "peerDependenciesMeta": {
        "@types/react": {
          "optional": true
        },
        "immer": {
          "optional": true
        },
        "react": {
          "optional": true
        },
        "use-sync-external-store": {
          "optional": true
        }
      }
    },
    "node_modules/zwitch": {
      "version": "2.0.4",
      "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
      "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
      "license": "MIT",
      "funding": {
        "type": "github",
        "url": "https://github.com/sponsors/wooorm"
      }
    }
  }
}

CLAUDE.md

Path: starbot_cloud/CLAUDE.md

Size: 5168 bytes

# CLAUDE.md — Starbot WebGUI

This is the web frontend for Starbot, an AI assistant system. It's a Next.js/React app that provides chat, project management, memory editing, and an admin console.

Live at `https://starbot.cloud/`. Runs on port 3001 behind nginx.

---

## Commands

```bash
npm install              # Install deps
npm run dev              # Dev server on port 3000
npm run build            # Production build (standalone output)
npm start                # Run production build (port 3001)
npm run lint             # ESLint
```

No test suite is configured.

## Deploy

```bash
./deploy.sh              # Build, publish static assets, install service, restart
```

The deploy script builds with `output: 'standalone'`, copies `.next/static/` and `public/` into the standalone directory, installs `deploy/starbot-webgui.service` to systemd, and restarts. Logs: `sudo journalctl -u starbot-webgui -f`

---

## Environment

No `.env` file needed for basic operation. Config comes from systemd environment variables in `deploy/starbot-webgui.service`:

- `PORT=3001`
- `NEXT_PUBLIC_API_URL=http://127.0.0.1:3737` — backend API
- `NEXT_PUBLIC_ADMIN_CONSOLE_HOSTS` — hostnames that trigger admin console mode
- `ADMIN_EMAILS`, `ADMIN_CONSOLE_CODE`, `ADMIN_COOKIE_SECRET` — admin auth

The API runs locally on port 3737. All `/v1/*` requests are proxied by nginx to the API; the WebGUI never talks to the API directly from the browser in production.

---

## Architecture

### How It Connects to the API

- `src/proxy.ts` — Next.js middleware that proxies `/v1/*` requests to the API server. This handles streaming (SSE) for chat generation.
- `src/lib/api.ts` — Base API client with auth token handling.
- `src/lib/api/*.ts` — Typed API functions (chats, projects, messages, folders, memory).

### Streaming Chat

The chat stream uses raw `fetch` + `ReadableStream` (NOT EventSource). The hook `src/hooks/use-chat-stream.ts` handles:
- Sending messages via `POST /v1/chats/:id/run`
- Parsing SSE events: `message.start`, `message.delta`, `message.stop`, `tool.call`, `tool.result`, `inference.complete`
- Updating UI state as tokens arrive

### Auth

- `src/lib/auth-session.ts` — Session management (token storage/retrieval)
- `src/app/api/auth/session/route.ts` — Server-side session endpoint
- `src/app/login/page.tsx` / `src/app/signup/page.tsx` — Auth pages
- Tokens are stored client-side and sent as `Authorization: Bearer <token>`

### Admin Console

Activated by hostname matching (e.g. `console.starbot.cloud`). Controlled by `NEXT_PUBLIC_ADMIN_CONSOLE_HOSTS` and `ADMIN_CONSOLE_HOSTS` env vars.

- `src/app/admin/` — Admin pages (identity, providers)
- `src/components/admin/admin-shell.tsx` — Admin layout wrapper
- `src/lib/server/admin-cookie.ts` — Server-side admin cookie auth

---

## Code Layout

```
src/
  proxy.ts            — Next.js middleware, proxies /v1/* to API
  app/                — Next.js App Router pages
    admin/           — Admin console pages
    api/auth/        — Session API route
  components/        — React components
    chat/            — Chat UI components
    ui/              — Shared UI primitives
  hooks/              — Custom React hooks (use-chat-stream)
  lib/                — Utilities and API clients
    api/             — Typed API functions
  store/              — Zustand state management

public/               — Static assets

deploy/               — Deployment configs (systemd, nginx)
scripts/              — Build helpers
Dockerfile            — Container build (alternative to deploy.sh)
```

---

## Production Build Notes

Next.js is configured with `output: 'standalone'` in `next.config.ts`. The standalone build produces a self-contained server at `.next/standalone/server.js`, but it does NOT include static assets. The deploy script handles this:

1. `npm run build` — produces `.next/standalone/` and `.next/static/`
2. Copy `.next/static/` → `.next/standalone/.next/static/`
3. Copy `public/` → `.next/standalone/public/`
4. Systemd runs `.next/standalone/server.js`

If static assets 404 in production, the copy step was missed.

---

## Common Tasks

**Add a new page:** Create `src/app/<route>/page.tsx`. Next.js App Router handles routing automatically.

**Add a new API call:** Add typed function in `src/lib/api/*.ts`, use it in components. All API calls go through the proxy middleware.

**Change chat behavior:** The streaming logic is in `src/hooks/use-chat-stream.ts`. The chat UI is in `src/components/chat/chat-view.tsx`.

**Change the sidebar:** `src/components/sidebar.tsx` handles project/chat navigation.

**Add a UI component:** Shared primitives go in `src/components/ui/`. Feature components go in `src/components/`.

**Debug streaming issues:** Use browser DevTools Network tab to inspect the `/v1/chats/:id/run` fetch response. Check the `use-chat-stream.ts` hook for parsing logic.

---

## Sibling Repos

- **API** — `/home/stella/starbot-api` (GitHub: `starbot-api`) — Fastify backend on port 3737
- **TUI** — Rust CLI client in the original monorepo at `/home/stella/projects/starbot/Starbot_TUI`
- **Monorepo** (reference) — `/home/stella/projects/starbot`

README.md

Path: starbot_cloud/README.md

Size: 1450 bytes

This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).

## Getting Started

First, run the development server:

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

tsconfig.json

Path: starbot_cloud/tsconfig.json

Size: 670 bytes

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    ".next/dev/types/**/*.ts",
    "**/*.mts"
  ],
  "exclude": ["node_modules"]
}

next.config.ts

Path: starbot_cloud/next.config.ts

Size: 152 bytes

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  reactCompiler: true,
  output: 'standalone',
};

export default nextConfig;

nginx-starbot.cloud.conf

Path: starbot_cloud/deploy/nginx-starbot.cloud.conf

Size: 3141 bytes

# Nginx configuration for starbot.cloud
# Place at: /etc/nginx/sites-available/starbot.cloud
# Symlink to: /etc/nginx/sites-enabled/starbot.cloud

upstream starbot_webgui {
    server 127.0.0.1:3001;
    keepalive 64;
}

upstream starbot_api {
    server 127.0.0.1:3737;
    keepalive 64;
}

server {
    listen 80;
    listen [::]:80;
    server_name starbot.cloud www.starbot.cloud sgauth0.com www.sgauth0.com console.sgauth0.com www.console.sgauth0.com console.starbot.cloud www.console.starbot.cloud;

    # Redirect to HTTPS
    return 301 https://$server_name$request_uri;
}

# HTTPS server
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name starbot.cloud www.starbot.cloud sgauth0.com www.sgauth0.com console.sgauth0.com www.console.sgauth0.com console.starbot.cloud www.console.starbot.cloud;

    ssl_certificate /etc/letsencrypt/live/starbot.cloud/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/starbot.cloud/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Root directory (for certbot challenges, etc.)
    root /var/www/sites/stella/starbot.cloud;

    # Increase upload size if needed
    client_max_body_size 50M;

    # API endpoints - proxy to Starbot_API (port 3737)
    location /v1/ {
        proxy_pass http://starbot_api;
        proxy_http_version 1.1;

        # Standard proxy headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # SSE / EventStream support
        # Note: SSE does NOT need Upgrade/Connection headers (those are for WebSockets)
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;

        # CORS is handled by the API, not nginx
        # Removed permissive CORS headers to prevent security issues
    }

    # Health endpoint
    location /health {
        proxy_pass http://starbot_api;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
    }

    # WebGUI - proxy to Next.js (port 3001)
    location / {
        proxy_pass http://starbot_webgui;
        proxy_http_version 1.1;

        # WebSocket/HMR support
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;

        # Forward real IP
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeouts
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }

    # Next.js static assets (optional optimization)
    location /_next/static {
        proxy_pass http://starbot_webgui;
        proxy_cache_valid 60m;
        add_header Cache-Control "public, immutable";
    }

    # Logging
    access_log /var/log/nginx/starbot.cloud.access.log;
    error_log /var/log/nginx/starbot.cloud.error.log;
}

deploy.sh

Path: starbot_cloud/scripts/deploy.sh

Size: 460 bytes

#!/usr/bin/env bash
set -euo pipefail

TARGET=/var/www/sites/stella/starbot.cloud
BUILD_DIR=.next
STANDALONE_DIR=$BUILD_DIR/standalone

npm install
npm run build

mkdir -p "$TARGET"
rm -rf "$TARGET"/*

rsync -a "$STANDALONE_DIR"/ "$TARGET/"
rsync -a "$BUILD_DIR/static"/ "$TARGET/.next/static/"
rsync -a public/ "$TARGET/public/"
cp -a package.json "$TARGET/package.json"
if [ -f package-lock.json ]; then
  cp package-lock.json "$TARGET/package-lock.json"
fi

proxy.ts

Path: starbot_cloud/src/proxy.ts

Size: 3122 bytes

import { NextRequest, NextResponse } from 'next/server';
import { getAdminCookieName, verifyAdminCookieToken } from '@/lib/server/admin-cookie';

const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3737';

function resolveConsoleHosts(): Set<string> {
  const configured = String(process.env.ADMIN_CONSOLE_HOSTS || process.env.NEXT_PUBLIC_ADMIN_CONSOLE_HOSTS || '')
    .split(',')
    .map((entry) => entry.trim().toLowerCase())
    .filter(Boolean);

  if (configured.length > 0) {
    return new Set(configured);
  }

  return new Set([
    'console.sgauth0.com',
    'www.console.sgauth0.com',
    'console.starbot.cloud',
    'www.console.starbot.cloud',
  ]);
}

const CONSOLE_HOSTS = resolveConsoleHosts();

function normalizeHost(rawHost: string | null): string {
  if (!rawHost) return '';
  return rawHost.split(':')[0].toLowerCase();
}

const ADMIN_COOKIE_NAME = getAdminCookieName();
const SESSION_COOKIE_NAME = 'starbot_auth';

function isPublicPath(pathname: string): boolean {
  return pathname === '/login' || pathname.startsWith('/api/auth') || pathname === '/signup';
}

export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Allow public paths for all hosts
  if (isPublicPath(pathname)) {
    return NextResponse.next();
  }

  // Check if this is a console host (admin area)
  const host = normalizeHost(request.headers.get('host'));
  const isConsoleHost = CONSOLE_HOSTS.has(host);

  if (isConsoleHost) {
    // Console host: check admin cookie
    const token = request.cookies.get(ADMIN_COOKIE_NAME)?.value;
    const isAdmin = await verifyAdminCookieToken(token);

    if (!isAdmin) {
      const url = request.nextUrl.clone();
      url.pathname = '/login';
      const nextPath = `${request.nextUrl.pathname}${request.nextUrl.search}`;
      url.searchParams.set('next', nextPath);
      return NextResponse.redirect(url);
    }

    if (isAdmin && pathname === '/login') {
      const url = request.nextUrl.clone();
      url.pathname = '/admin';
      url.search = '';
      return NextResponse.redirect(url);
    }

    if (isAdmin && pathname === '/') {
      const url = request.nextUrl.clone();
      url.pathname = '/admin';
      return NextResponse.rewrite(url);
    }

    return NextResponse.next();
  }

  // Non-console host: check auth token
  const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME);

  // No session? Redirect to login
  if (!sessionCookie) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Verify token with backend
  try {
    const response = await fetch(`${API_URL}/v1/auth/me`, {
      headers: {
        Authorization: `Bearer ${sessionCookie.value}`,
      },
    });

    if (!response.ok) {
      const redirectResponse = NextResponse.redirect(new URL('/login', request.url));
      redirectResponse.cookies.delete(SESSION_COOKIE_NAME);
      return redirectResponse;
    }
  } catch (e) {
    // Backend unavailable - let through
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

use-chat-stream.ts

Path: starbot_cloud/src/hooks/use-chat-stream.ts

Size: 6503 bytes

import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { chatsApi } from '@/lib/api/chats';
import { API_BASE_URL } from '@/lib/config';
import { Message, Settings } from '@/lib/types';
import { toast } from 'sonner';

export function useChatStream(chatId: string | null) {
  const queryClient = useQueryClient();
  const [status, setStatus] = useState<string>('');
  const abortControllerRef = useRef<AbortController | null>(null);

  const { data: messages, isLoading, error } = useQuery({
    queryKey: ['messages', chatId],
    queryFn: () => chatId ? chatsApi.getMessages(chatId) : Promise.resolve([]),
    enabled: !!chatId,
  });

  const startStream = async (settings?: Partial<Settings>) => {
    if (!chatId) return;

    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
    }

    const controller = new AbortController();
    abortControllerRef.current = controller;

    try {
      const response = await fetch(`${API_BASE_URL}/chats/${chatId}/run`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream',
        },
        body: JSON.stringify({
          mode: settings?.mode || 'standard',
          auto: settings?.auto ?? true,
          speed: settings?.speed ?? false,
          model_prefs: settings?.model_prefs,
        }),
        signal: controller.signal,
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const reader = response.body?.getReader();
      if (!reader) throw new Error('No response body');

      const decoder = new TextDecoder();
      let buffer = '';
      let currentEvent = 'message';
      let currentDataLines: string[] = [];

      const flushCurrentEvent = () => {
        if (currentDataLines.length === 0) {
          currentEvent = 'message';
          return;
        }

        const rawData = currentDataLines.join('\n');
        currentDataLines = [];

        try {
          const parsedData = JSON.parse(rawData);
          handleSSEEvent(currentEvent, parsedData);
        } catch (parseError) {
          console.error('Failed to parse SSE event payload:', parseError, rawData);
        }

        currentEvent = 'message';
      };

      const processLine = (rawLine: string) => {
        const line = rawLine.replace(/\r$/, '');

        if (!line) {
          flushCurrentEvent();
          return;
        }

        // SSE comments begin with ":" and should be ignored.
        if (line.startsWith(':')) {
          return;
        }

        if (line.startsWith('event:')) {
          currentEvent = line.slice(6).trim() || 'message';
          return;
        }

        if (line.startsWith('data:')) {
          currentDataLines.push(line.slice(5).trimStart());
        }
      };

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop() || '';

        for (const line of lines) {
          processLine(line);
        }
      }

      // Handle any trailing line/event if the stream closes without a blank line.
      if (buffer.length > 0) {
        processLine(buffer);
      }
      flushCurrentEvent();

      // Clear status when stream completes normally
      setStatus('');
    } catch (err) {
      if (err instanceof Error && err.name === 'AbortError') {
        // Stream was aborted (e.g., user sent a new message) - reset status
        setStatus('');
        return;
      }
      if (err instanceof Error) {
        console.error('Stream error:', err);
        const errorMessage = err.message.includes('Failed to fetch')
          ? 'Network error. Please check your connection.'
          : err.message;
        toast.error(errorMessage);
        setStatus('');
      }
    }
  };

  const handleSSEEvent = (eventType: string, data: any) => {
    switch (eventType) {
      case 'status':
        setStatus(data.message || '');
        break;

      case 'token.delta':
        queryClient.setQueryData<Message[]>(['messages', chatId], (old) => {
          const messages = old ?? [];
          const lastMsg = messages[messages.length - 1];

          if (lastMsg?.role === 'assistant' && !lastMsg.metadata?.final) {
            return [
              ...messages.slice(0, -1),
              { ...lastMsg, content: lastMsg.content + (data.text || data.delta) }
            ];
          } else {
            return [...messages, {
              id: data.message_id || 'temp-assistant',
              chatId: chatId!,
              role: 'assistant',
              content: data.text || data.delta || '',
              createdAt: new Date().toISOString(),
            }];
          }
        });
        break;

      case 'message.final':
        queryClient.setQueryData<Message[]>(['messages', chatId], (old) => {
          const messages = old ?? [];
          const lastMsg = messages[messages.length - 1];

          if (lastMsg?.role === 'assistant') {
            return [...messages.slice(0, -1), {
              id: data.message_id || data.id,
              chatId: chatId!,
              role: 'assistant',
              content: data.content,
              createdAt: new Date().toISOString(),
              metadata: { final: true, ...data.usage },
            }];
          }

          return [...messages, {
            id: data.message_id || data.id || 'temp-assistant-final',
            chatId: chatId!,
            role: 'assistant',
            content: data.content || '',
            createdAt: new Date().toISOString(),
            metadata: { final: true, ...data.usage },
          }];
        });
        setStatus('');
        break;

      case 'message.stop':
        setStatus('');
        break;

      case 'inference.complete':
        setStatus('');
        break;

      case 'chat.updated':
        queryClient.invalidateQueries({ queryKey: ['chat', chatId] });
        break;

      case 'error':
      case 'run.error':
        toast.error(data.message || 'An error occurred');
        setStatus('');
        break;
    }
  };

  useEffect(() => {
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [chatId]);

  return { messages, isLoading, error, status, startStream };
}

logs-panel.tsx

Path: starbot_cloud/src/components/logs-panel.tsx

Size: 2141 bytes

import { useUIStore } from '@/store/ui-store';
import { Terminal } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { Message } from '@/lib/types';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';

export function LogsPanel() {
  const { isLogsOpen, setLogsOpen, selectedChatId } = useUIStore();
  const queryClient = useQueryClient();

  const messages = selectedChatId
    ? queryClient.getQueryData<Message[]>(['messages', selectedChatId])
    : [];

  const logs = messages?.filter(m => m.role === 'tool' || m.role === 'system') || [];

  return (
    <Dialog open={isLogsOpen} onOpenChange={setLogsOpen}>
      <DialogContent className="sm:max-w-2xl max-h-[80vh] flex flex-col p-0">
        <DialogHeader className="px-6 pt-6 pb-4 border-b">
          <div className="flex items-center gap-2">
            <Terminal className="h-4 w-4" />
            <DialogTitle>Diagnostics / Logs</DialogTitle>
          </div>
        </DialogHeader>

        <ScrollArea className="flex-1 px-6 py-4">
            {logs.length === 0 ? (
                <div className="text-sm text-slate-500 italic">No logs available for this chat.</div>
            ) : (
                <div className="space-y-4">
                    {logs.map((log, i) => (
                        <div key={i} className="text-xs font-mono space-y-1 border-b pb-2 last:border-0">
                            <div className="font-semibold text-slate-700 uppercase">{log.role}</div>
                            <div className="text-slate-600 whitespace-pre-wrap">{log.content}</div>
                            {log.metadata && (
                                <pre className="bg-slate-100 p-1 rounded text-[10px] overflow-auto">
                                    {JSON.stringify(log.metadata, null, 2)}
                                </pre>
                            )}
                        </div>
                    ))}
                </div>
            )}
        </ScrollArea>
      </DialogContent>
    </Dialog>
  );
}

account-menu.tsx

Path: starbot_cloud/src/components/account-menu.tsx

Size: 4176 bytes

'use client';

import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { CircleUserRound, LogIn, LogOut, Settings, ShieldCheck, TerminalSquare, UserRound } from 'lucide-react';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/store/ui-store';
import { toast } from 'sonner';
import {
  AUTH_CHANGED_EVENT,
  clearAuthSession,
  clearServerSession,
  readAuthSession,
  type AuthSession,
} from '@/lib/auth-session';

const ADMIN_CONSOLE_URL = process.env.NEXT_PUBLIC_ADMIN_CONSOLE_URL || 'https://console.starbot.cloud';

export function AccountMenu() {
  const router = useRouter();
  const { openSettings, openLogs } = useUIStore();
  const [session, setSession] = useState<AuthSession | null>(null);

  useEffect(() => {
    const refresh = () => setSession(readAuthSession());
    refresh();

    const onStorage = (event: StorageEvent) => {
      if (!event.key || event.key === 'starbot_session') {
        refresh();
      }
    };

    window.addEventListener('storage', onStorage);
    window.addEventListener(AUTH_CHANGED_EVENT, refresh);
    return () => {
      window.removeEventListener('storage', onStorage);
      window.removeEventListener(AUTH_CHANGED_EVENT, refresh);
    };
  }, []);

  const initials = useMemo(() => {
    const label = session?.name || session?.email || 'Guest';
    return label
      .split(/\s+/)
      .map((part) => part[0]?.toUpperCase())
      .filter(Boolean)
      .slice(0, 2)
      .join('') || 'G';
  }, [session]);

  const handleSignOut = async () => {
    await clearServerSession();
    clearAuthSession();
    toast.success('Signed out');
    router.push('/login');
  };

  const openAdminConsole = () => {
    if (typeof window !== 'undefined') {
      window.location.href = ADMIN_CONSOLE_URL;
    }
  };

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          variant="ghost"
          className="h-10 rounded-xl px-2 text-slate-700 hover:bg-slate-100"
          aria-label="Open account menu"
        >
          <span className="mr-2 inline-flex h-7 w-7 items-center justify-center rounded-lg bg-slate-900 text-xs font-semibold text-white">
            {initials}
          </span>
          <CircleUserRound className="h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>

      <DropdownMenuContent align="end" className="w-60 rounded-xl border-slate-200 p-2">
        <DropdownMenuLabel className="space-y-0.5 px-2 py-2">
          <div className="text-xs font-semibold uppercase tracking-wide text-slate-500">Account</div>
          <div className="truncate text-sm text-slate-900">{session?.name || 'Guest'}</div>
          <div className="truncate text-xs text-slate-500">{session?.email || 'Not signed in'}</div>
        </DropdownMenuLabel>

        <DropdownMenuSeparator />

        <DropdownMenuItem onClick={() => router.push(session ? '/account' : '/login')}>
          {session ? <UserRound className="mr-2 h-4 w-4" /> : <LogIn className="mr-2 h-4 w-4" />}
          {session ? 'Account' : 'Log in'}
        </DropdownMenuItem>

        <DropdownMenuItem onClick={openSettings}>
          <Settings className="mr-2 h-4 w-4" />
          Settings
        </DropdownMenuItem>

        <DropdownMenuItem onClick={openLogs}>
          <TerminalSquare className="mr-2 h-4 w-4" />
          Diagnostics
        </DropdownMenuItem>

        {session?.role === 'admin' && (
          <DropdownMenuItem onClick={openAdminConsole}>
            <ShieldCheck className="mr-2 h-4 w-4" />
            Admin Console
          </DropdownMenuItem>
        )}

        {session && (
          <>
            <DropdownMenuSeparator />
            <DropdownMenuItem onClick={handleSignOut} className="text-red-600 focus:text-red-600">
              <LogOut className="mr-2 h-4 w-4" />
              Sign out
            </DropdownMenuItem>
          </>
        )}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

error-boundary.tsx

Path: starbot_cloud/src/components/error-boundary.tsx

Size: 2148 bytes

"use client"

import React from "react"
import { AlertTriangle } from "lucide-react"
import { Button } from "@/components/ui/button"

interface ErrorBoundaryProps {
  children: React.ReactNode
}

interface ErrorBoundaryState {
  hasError: boolean
  error: Error | null
}

export class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  constructor(props: ErrorBoundaryProps) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error("Error caught by boundary:", error, errorInfo)
  }

  handleReload = () => {
    this.setState({ hasError: false, error: null })
    window.location.reload()
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="flex h-screen items-center justify-center bg-background p-4">
          <div className="text-center space-y-4 max-w-md">
            <div className="flex justify-center">
              <div className="rounded-full bg-destructive/10 p-6">
                <AlertTriangle className="h-12 w-12 text-destructive" />
              </div>
            </div>
            <div className="space-y-2">
              <h1 className="text-2xl font-bold">Something went wrong</h1>
              <p className="text-sm text-muted-foreground">
                An unexpected error occurred. Please try reloading the page.
              </p>
              {this.state.error && (
                <details className="mt-4 text-left">
                  <summary className="cursor-pointer text-sm font-medium">
                    Error details
                  </summary>
                  <pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto">
                    {this.state.error.message}
                  </pre>
                </details>
              )}
            </div>
            <Button onClick={this.handleReload}>Reload Page</Button>
          </div>
        </div>
      )
    }

    return this.props.children
  }
}

sidebar.tsx

Path: starbot_cloud/src/components/sidebar.tsx

Size: 18031 bytes

'use client';

import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { chatsApi } from '@/lib/api/chats';
import { foldersApi } from '@/lib/api/folders';
import { projectsApi } from '@/lib/api/projects';
import { useUIStore } from '@/store/ui-store';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  DragEndEvent,
} from '@dnd-kit/core';
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Plus, MessageSquare, Folder as FolderIcon, ChevronDown, ChevronRight, Trash2, MoreVertical, Pencil } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import { ApiError } from '@/lib/api';
import { readAuthSession } from '@/lib/auth-session';
import { Chat } from '@/lib/types';

export function Sidebar() {
  const {
    selectedChatId,
    setSelectedChatId,
    isSidebarOpen
  } = useUIStore();

  const queryClient = useQueryClient();
  const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
  const [localChats, setLocalChats] = useState<Chat[]>([]);
  const [isMounted, setIsMounted] = useState(false);

  // Prevent SSR issues with dnd-kit
  useEffect(() => {
    setIsMounted(true);
  }, []);

  const sensors = useSensors(
    useSensor(PointerSensor, {
      activationConstraint: {
        distance: 8,
      },
    }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  const session = readAuthSession();
  const userEmail = session?.email;

  const { data: projects } = useQuery({
    queryKey: ['projects', userEmail],
    queryFn: () => userEmail ? projectsApi.list(userEmail) : Promise.resolve([]),
    enabled: !!userEmail,
  });

  const currentProjectId = projects?.[0]?.id;

  const { data: folders } = useQuery({
    queryKey: ['folders', currentProjectId],
    queryFn: () => currentProjectId ? foldersApi.list(currentProjectId) : Promise.resolve([]),
    enabled: !!currentProjectId,
  });

  const { data: chats } = useQuery({
    queryKey: ['chats', currentProjectId],
    queryFn: () => currentProjectId ? chatsApi.list(currentProjectId, 'webgui') : Promise.resolve([]),
    enabled: !!currentProjectId,
  });

  const createChatMutation = useMutation({
    mutationFn: async ({ title, folderId }: { title: string; folderId?: string }) => {
      if (!userEmail) throw new Error('Not logged in');

      let projectId = currentProjectId;

      if (!projectId) {
        const project = await projectsApi.create({ name: 'My Project', email: userEmail });
        projectId = project.id;
      }

      return chatsApi.create(projectId, { title, folderId, clientSource: 'webgui' });
    },
    onSuccess: (newChat) => {
      queryClient.invalidateQueries({ queryKey: ['projects'] });
      if (currentProjectId) {
        queryClient.invalidateQueries({ queryKey: ['chats', currentProjectId] });
      }
      setSelectedChatId(newChat.id);
    },
    onError: (error) => {
      const message = error instanceof ApiError
        ? error.message
        : error instanceof Error
          ? error.message
          : 'Failed to create chat';
      toast.error(message);
    },
  });

  const createFolderMutation = useMutation({
    mutationFn: async (name: string) => {
      if (!currentProjectId) throw new Error('No project selected');
      return foldersApi.create(currentProjectId, { name });
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['folders', currentProjectId] });
      toast.success('Folder created');
    },
    onError: (error) => {
      const message = error instanceof ApiError
        ? error.message
        : error instanceof Error
          ? error.message
          : 'Failed to create folder';
      toast.error(message);
    },
  });

  const deleteFolderMutation = useMutation({
    mutationFn: (id: string) => foldersApi.delete(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['folders', currentProjectId] });
      queryClient.invalidateQueries({ queryKey: ['chats', currentProjectId] });
      toast.success('Folder deleted (chats moved to root)');
    },
    onError: (error) => {
      toast.error('Failed to delete folder');
    },
  });

  const deleteChatMutation = useMutation({
    mutationFn: (id: string) => chatsApi.delete(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['chats', currentProjectId] });
      toast.success('Chat deleted');
    },
    onError: () => {
      toast.error('Failed to delete chat');
    },
  });

  const renameChatMutation = useMutation({
    mutationFn: ({ id, title }: { id: string; title: string }) => chatsApi.update(id, { title }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['chats', currentProjectId] });
      toast.success('Chat renamed');
    },
    onError: () => {
      toast.error('Failed to rename chat');
    },
  });

  const toggleFolder = (folderId: string) => {
    setExpandedFolders(prev => {
      const next = new Set(prev);
      if (next.has(folderId)) {
        next.delete(folderId);
      } else {
        next.add(folderId);
      }
      return next;
    });
  };

  const handleCreateChat = (folderId?: string) => {
    createChatMutation.mutate({ title: 'New Chat', folderId });
  };

  const handleCreateFolder = () => {
    const name = prompt('Enter folder name:');
    if (name?.trim()) {
      createFolderMutation.mutate(name.trim());
    }
  };

  const handleDeleteFolder = (e: React.MouseEvent, folderId: string) => {
    e.stopPropagation();
    if (confirm('Delete folder? Chats will be moved to root.')) {
      deleteFolderMutation.mutate(folderId);
    }
  };

  const handleDeleteChat = (e: React.MouseEvent, chatId: string) => {
    e.stopPropagation();
    if (confirm('Delete this chat?')) {
      deleteChatMutation.mutate(chatId);
    }
  };

  const handleRenameChat = (e: React.MouseEvent, chatId: string, currentTitle: string) => {
    e.stopPropagation();
    const newTitle = prompt('Enter new title:', currentTitle);
    if (newTitle?.trim() && newTitle !== currentTitle) {
      renameChatMutation.mutate({ id: chatId, title: newTitle.trim() });
    }
  };

  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;

    if (over && active.id !== over.id && unfolderedChats) {
      const oldIndex = unfolderedChats.findIndex((c) => c.id === active.id);
      const newIndex = unfolderedChats.findIndex((c) => c.id === over.id);

      if (oldIndex !== -1 && newIndex !== -1) {
        const newOrder = arrayMove(unfolderedChats, oldIndex, newIndex);
        setLocalChats(newOrder);
      }
    }
  };

  // Sync localChats when chats change
  const effectiveChats = localChats.length > 0 && chats ? localChats : chats;

  // Separate chats into folder groups
  const chatsInFolders = new Map<string, typeof chats>();
  const unfolderedChats: typeof chats = [];

  effectiveChats?.forEach(chat => {
    if (chat.folderId) {
      const folderChats = chatsInFolders.get(chat.folderId) || [];
      folderChats.push(chat);
      chatsInFolders.set(chat.folderId, folderChats);
    } else {
      unfolderedChats.push(chat);
    }
  });

  // Sortable chat item component
  function SortableChatItem({ chat, isSelected }: { chat: Chat; isSelected: boolean }) {
    const {
      attributes,
      listeners,
      setNodeRef,
      transform,
      transition,
      isDragging,
    } = useSortable({ id: chat.id });

    const style = {
      transform: CSS.Transform.toString(transform),
      transition,
    };

    return (
      <div
        ref={setNodeRef}
        style={style}
        className={cn(
          "flex items-center group",
          isDragging && "opacity-50"
        )}
      >
        <Button
          variant="ghost"
          className={cn(
            "flex-1 justify-start font-normal h-10 rounded-xl px-2 pr-1",
            isSelected
              ? "bg-slate-900 text-white hover:bg-slate-800 hover:text-white"
              : "text-slate-700 hover:bg-slate-100"
          )}
          onClick={() => setSelectedChatId(chat.id)}
          {...attributes}
          {...listeners}
        >
          <MessageSquare className="mr-2 h-4 w-4 shrink-0" />
          <span className="truncate flex-1 text-left">{chat.title}</span>
        </Button>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button
              variant="ghost"
              size="sm"
              className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 hover:bg-slate-100"
              onClick={(e) => e.stopPropagation()}
            >
              <MoreVertical className="h-4 w-4" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end" className="w-40">
            <DropdownMenuItem onClick={(e) => handleRenameChat(e, chat.id, chat.title)}>
              <Pencil className="mr-2 h-4 w-4" />
              Rename
            </DropdownMenuItem>
            <DropdownMenuItem
              onClick={(e) => handleDeleteChat(e, chat.id)}
              className="text-red-600 focus:text-red-600"
            >
              <Trash2 className="mr-2 h-4 w-4" />
              Delete
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </div>
    );
  }

  if (!isSidebarOpen) return null;

  return (
    <aside className="h-full w-full rounded-3xl border border-slate-200/80 bg-white/80 shadow-[0_16px_40px_rgba(15,23,42,0.08)] backdrop-blur supports-[backdrop-filter]:bg-white/65 flex flex-col overflow-visible">
      <div className="px-4 py-4 border-b border-slate-200/80 bg-gradient-to-r from-slate-50 to-white">
        <div className="mb-3">
          <p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">Starbot</p>
          <h2 className="text-sm font-semibold text-slate-900">Conversations</h2>
        </div>
        <div className="flex gap-2">
          <Button
            onClick={() => handleCreateChat()}
            className="flex-1 justify-start bg-slate-900 text-slate-50 hover:bg-slate-800 border-0 shadow-sm"
            aria-label="Create new chat"
            disabled={createChatMutation.isPending}
          >
            <Plus className="mr-2 h-4 w-4" />
            New Chat
          </Button>
          <Button
            onClick={handleCreateFolder}
            variant="outline"
            size="sm"
            className="border-slate-300"
            aria-label="Create new folder"
            disabled={createFolderMutation.isPending}
          >
            <FolderIcon className="h-4 w-4" />
          </Button>
        </div>
      </div>

      <ScrollArea className="flex-1 px-2 py-3 overflow-y-auto">
        <div className="space-y-2" role="list" aria-label="Chat list">
          {/* Render folders with nested chats */}
          {folders?.map((folder) => {
            const isExpanded = expandedFolders.has(folder.id);
            const folderChats = chatsInFolders.get(folder.id) || [];

            return (
              <div key={folder.id}>
                <Button
                  variant="ghost"
                  className="w-full justify-start font-semibold h-9 rounded-xl px-3 text-slate-700 hover:bg-slate-100"
                  onClick={() => toggleFolder(folder.id)}
                >
                  {isExpanded ? (
                    <ChevronDown className="mr-2 h-4 w-4" />
                  ) : (
                    <ChevronRight className="mr-2 h-4 w-4" />
                  )}
                  <FolderIcon className="mr-2 h-4 w-4 text-slate-500" />
                  <span className="truncate flex-1 text-left">{folder.name}</span>
                  <span className="text-xs text-slate-400 ml-2">({folderChats.length})</span>
                  <button
                    onClick={(e) => handleDeleteFolder(e, folder.id)}
                    className="ml-2 p-1 hover:bg-red-100 rounded"
                    aria-label="Delete folder"
                  >
                    <Trash2 className="h-3 w-3 text-slate-400 hover:text-red-500" />
                  </button>
                </Button>

                {isExpanded && (
                  <div className="ml-4 space-y-1">
                    {folderChats.map((chat) => (
                      <div key={chat.id} className="flex items-center group">
                        <Button
                          variant="ghost"
                          className={cn(
                            "flex-1 justify-start font-normal h-8 rounded-lg px-2 pr-1 ml-2",
                            selectedChatId === chat.id
                              ? "bg-slate-900 text-white hover:bg-slate-800 hover:text-white"
                              : "text-slate-600 hover:bg-slate-100"
                          )}
                          onClick={() => setSelectedChatId(chat.id)}
                        >
                          <MessageSquare className="mr-2 h-3.5 w-3.5" />
                          <span className="truncate text-sm flex-1 text-left">{chat.title}</span>
                        </Button>
                        <DropdownMenu>
                          <DropdownMenuTrigger asChild>
                            <Button
                              variant="ghost"
                              size="sm"
                              className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-slate-100"
                              onClick={(e) => e.stopPropagation()}
                            >
                              <MoreVertical className="h-3 w-3" />
                            </Button>
                          </DropdownMenuTrigger>
                          <DropdownMenuContent align="end" className="w-36">
                            <DropdownMenuItem onClick={(e) => handleRenameChat(e, chat.id, chat.title)}>
                              <Pencil className="mr-2 h-3 w-3" />
                              Rename
                            </DropdownMenuItem>
                            <DropdownMenuItem
                              onClick={(e) => handleDeleteChat(e, chat.id)}
                              className="text-red-600 focus:text-red-600"
                            >
                              <Trash2 className="mr-2 h-3 w-3" />
                              Delete
                            </DropdownMenuItem>
                          </DropdownMenuContent>
                        </DropdownMenu>
                      </div>
                    ))}
                    <Button
                      variant="ghost"
                      className="w-full justify-start font-normal h-8 rounded-lg px-3 ml-2 text-slate-400 hover:text-slate-600 hover:bg-slate-50"
                      onClick={() => handleCreateChat(folder.id)}
                    >
                      <Plus className="mr-2 h-3.5 w-3.5" />
                      <span className="text-xs">Add chat</span>
                    </Button>
                  </div>
                )}
              </div>
            );
          })}

          {/* Render unfoldered chats with drag-drop when mounted */}
          {isMounted && unfolderedChats && unfolderedChats.length > 0 && (
            <DndContext
              sensors={sensors}
              collisionDetection={closestCenter}
              onDragEnd={handleDragEnd}
            >
              <SortableContext
                items={unfolderedChats.map(c => c.id)}
                strategy={verticalListSortingStrategy}
              >
                <div className="space-y-1">
                  {folders && folders.length > 0 && (
                    <p className="text-xs font-medium text-slate-400 px-3 py-1">Unfiled</p>
                  )}
                  {unfolderedChats.map((chat) => (
                    <SortableChatItem
                      key={chat.id}
                      chat={chat}
                      isSelected={selectedChatId === chat.id}
                    />
                  ))}
                </div>
              </SortableContext>
            </DndContext>
          )}

          {/* Show loading state before mount */}
          {!isMounted && (
            <div className="space-y-1">
              {unfolderedChats && unfolderedChats.length > 0 && (
                <>
                  {folders && folders.length > 0 && (
                    <p className="text-xs font-medium text-slate-400 px-3 py-1">Unfiled</p>
                  )}
                  {unfolderedChats.map((chat) => (
                    <Button
                      key={chat.id}
                      variant="ghost"
                      className="w-full justify-start font-normal h-10 rounded-xl px-3"
                    >
                      <MessageSquare className="mr-2 h-4 w-4" />
                      <span className="truncate">{chat.title}</span>
                    </Button>
                  ))}
                </>
              )}
            </div>
          )}

          {!chats?.length && (
            <div className="mx-2 mt-4 rounded-xl border border-dashed border-slate-300 bg-slate-50/80 px-4 py-6 text-sm text-slate-500 text-center">
              No chats yet. Click New Chat to create one.
            </div>
          )}
        </div>
      </ScrollArea>

      <div className="p-4 border-t border-slate-200/80 mt-auto bg-white/70">
        <p className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Use account menu for settings</p>
      </div>
    </aside>
  );
}

network-status.tsx

Path: starbot_cloud/src/components/network-status.tsx

Size: 685 bytes

"use client"

import { useEffect, useState } from "react"
import { toast } from "sonner"

export function NetworkStatus() {
  const [isOnline, setIsOnline] = useState(true)

  useEffect(() => {
    const handleOnline = () => {
      setIsOnline(true)
      toast.success("Connection restored")
    }

    const handleOffline = () => {
      setIsOnline(false)
      toast.error("No internet connection")
    }

    window.addEventListener("online", handleOnline)
    window.addEventListener("offline", handleOffline)

    return () => {
      window.removeEventListener("online", handleOnline)
      window.removeEventListener("offline", handleOffline)
    }
  }, [])

  return null
}

settings-panel.tsx

Path: starbot_cloud/src/components/settings-panel.tsx

Size: 15460 bytes

import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useUIStore } from '@/store/ui-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { memoryApi } from '@/lib/api/memory';
import { toast } from 'sonner';
import { Bot, Brain, Gauge, IdCard, MessageSquareText, Sparkles, X } from 'lucide-react';

type SettingsTab = 'routing' | 'identity' | 'chat';

function formatTimestamp(value?: string) {
  if (!value) return 'Never';
  const parsed = new Date(value);
  if (Number.isNaN(parsed.getTime())) return 'Unknown';
  return parsed.toLocaleString();
}

export function SettingsPanel() {
  const {
    isSettingsOpen,
    setSettingsOpen,
    settings,
    updateSettings,
    selectedChatId,
  } = useUIStore();

  const [activeTab, setActiveTab] = useState<SettingsTab>('routing');
  const [modelPrefs, setModelPrefs] = useState(settings.model_prefs || '');
  const [identityDraft, setIdentityDraft] = useState('');
  const [chatMemoryDraft, setChatMemoryDraft] = useState('');

  useEffect(() => {
    setModelPrefs(settings.model_prefs || '');
  }, [settings.model_prefs]);

  const identityQuery = useQuery({
    queryKey: ['identity-memory'],
    queryFn: memoryApi.getIdentity,
    enabled: isSettingsOpen,
  });

  const chatMemoryQuery = useQuery({
    queryKey: ['chat-memory', selectedChatId],
    queryFn: () => memoryApi.getChatMemory(selectedChatId!),
    enabled: isSettingsOpen && !!selectedChatId,
  });

  useEffect(() => {
    if (identityQuery.data) {
      setIdentityDraft(identityQuery.data.content);
    }
  }, [identityQuery.data]);

  useEffect(() => {
    if (chatMemoryQuery.data) {
      setChatMemoryDraft(chatMemoryQuery.data.content);
    }
  }, [chatMemoryQuery.data]);

  const saveIdentityMutation = useMutation({
    mutationFn: async (content: string) => {
      await memoryApi.updateIdentity(content);
      await memoryApi.processIdentity();
      return memoryApi.getIdentity();
    },
    onSuccess: () => {
      toast.success('Identity saved');
      identityQuery.refetch();
    },
    onError: (error) => {
      toast.error(error instanceof Error ? error.message : 'Failed to save identity');
    },
  });

  const saveChatMemoryMutation = useMutation({
    mutationFn: async (content: string) => {
      if (!selectedChatId) throw new Error('Select a chat first');
      await memoryApi.updateChatMemory(selectedChatId, content);
      await memoryApi.processChatMemory(selectedChatId);
      return memoryApi.getChatMemory(selectedChatId);
    },
    onSuccess: () => {
      toast.success('Chat memory saved');
      chatMemoryQuery.refetch();
    },
    onError: (error) => {
      toast.error(error instanceof Error ? error.message : 'Failed to save chat memory');
    },
  });

  const saveModelPrefs = () => {
    updateSettings({ model_prefs: modelPrefs.trim() || undefined });
    toast.success('Model preference saved');
  };

  return (
    <Dialog open={isSettingsOpen} onOpenChange={setSettingsOpen}>
      <DialogContent
        className="sm:max-w-4xl border-slate-200 bg-white p-0"
        onEscapeKeyDown={(event) => event.preventDefault()}
        onInteractOutside={(event) => event.preventDefault()}
        onPointerDownOutside={(event) => event.preventDefault()}
      >
        <button
          type="button"
          aria-label="Close settings"
          onClick={() => setSettingsOpen(false)}
          className="absolute right-4 top-4 z-20 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-slate-300 bg-white text-slate-700 shadow-sm transition hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
        >
          <X className="h-4 w-4" />
        </button>

        <DialogHeader className="border-b border-slate-200 bg-gradient-to-r from-slate-50 to-white px-6 py-5">
          <DialogTitle className="text-xl text-slate-900">Settings</DialogTitle>
          <DialogDescription className="text-slate-600">
            Configure routing, global identity, and per-chat memory.
          </DialogDescription>
        </DialogHeader>

        <div className="border-b border-slate-200 px-6 py-3">
          <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
            <Button
              type="button"
              variant="outline"
              onClick={() => setActiveTab('routing')}
              className={activeTab === 'routing'
                ? 'rounded-xl border-slate-900 bg-slate-900 text-white hover:bg-slate-800 hover:text-white'
                : 'rounded-xl border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}
            >
              <Brain className="mr-2 h-4 w-4" />
              Routing
            </Button>
            <Button
              type="button"
              variant="outline"
              onClick={() => setActiveTab('identity')}
              className={activeTab === 'identity'
                ? 'rounded-xl border-slate-900 bg-slate-900 text-white hover:bg-slate-800 hover:text-white'
                : 'rounded-xl border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}
            >
              <IdCard className="mr-2 h-4 w-4" />
              Identity
            </Button>
            <Button
              type="button"
              variant="outline"
              onClick={() => setActiveTab('chat')}
              className={activeTab === 'chat'
                ? 'rounded-xl border-slate-900 bg-slate-900 text-white hover:bg-slate-800 hover:text-white'
                : 'rounded-xl border-slate-300 bg-white text-slate-700 hover:bg-slate-50'}
            >
              <MessageSquareText className="mr-2 h-4 w-4" />
              Chat Memory
            </Button>
          </div>
        </div>

        <div className="space-y-5 px-6 py-6 max-h-[72vh] overflow-y-auto">
          {activeTab === 'routing' && (
            <>
              <section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
                <div className="mb-3">
                  <h3 className="text-sm font-semibold text-slate-900 flex items-center gap-2">
                    <Brain className="h-4 w-4 text-slate-600" />
                    Reasoning Mode
                  </h3>
                  <p className="text-xs text-slate-500">Controls depth and cost profile.</p>
                </div>

                <div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
                  {[
                    { id: 'quick', label: 'Quick', description: 'Fastest responses, minimal depth.' },
                    { id: 'standard', label: 'Standard', description: 'Balanced speed and quality.' },
                    { id: 'deep', label: 'Deep', description: 'Most deliberate and thorough.' },
                  ].map((mode) => (
                    <Button
                      key={mode.id}
                      type="button"
                      variant="outline"
                      onClick={() => updateSettings({ mode: mode.id as 'quick' | 'standard' | 'deep' })}
                      className={`h-auto items-start justify-start px-3 py-3 text-left rounded-xl border ${
                        settings.mode === mode.id
                          ? 'bg-slate-900 text-white border-slate-900 hover:bg-slate-800 hover:text-white'
                          : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-50'
                      }`}
                    >
                      <span className="space-y-1">
                        <span className="block text-sm font-medium">{mode.label}</span>
                        <span className="block text-xs opacity-80">{mode.description}</span>
                      </span>
                    </Button>
                  ))}
                </div>
              </section>

              <section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
                <div className="mb-3">
                  <h3 className="text-sm font-semibold text-slate-900 flex items-center gap-2">
                    <Gauge className="h-4 w-4 text-slate-600" />
                    Execution Speed
                  </h3>
                  <p className="text-xs text-slate-500">Trade output length for faster turnaround.</p>
                </div>
                <div className="flex gap-2">
                  <Button
                    type="button"
                    variant="outline"
                    onClick={() => updateSettings({ speed: true })}
                    className={`flex-1 rounded-xl ${settings.speed
                      ? 'bg-slate-900 text-white border-slate-900 hover:bg-slate-800 hover:text-white'
                      : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-50'}`}
                  >
                    Fast
                  </Button>
                  <Button
                    type="button"
                    variant="outline"
                    onClick={() => updateSettings({ speed: false })}
                    className={`flex-1 rounded-xl ${!settings.speed
                      ? 'bg-slate-900 text-white border-slate-900 hover:bg-slate-800 hover:text-white'
                      : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-50'}`}
                  >
                    Quality
                  </Button>
                </div>
              </section>

              <section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
                <div className="mb-3">
                  <h3 className="text-sm font-semibold text-slate-900 flex items-center gap-2">
                    <Sparkles className="h-4 w-4 text-slate-600" />
                    Automation
                  </h3>
                  <p className="text-xs text-slate-500">Auto-route based on request complexity.</p>
                </div>
                <Button
                  type="button"
                  variant="outline"
                  onClick={() => updateSettings({ auto: !settings.auto })}
                  className={`rounded-xl ${settings.auto
                    ? 'bg-slate-900 text-white border-slate-900 hover:bg-slate-800 hover:text-white'
                    : 'bg-white text-slate-700 border-slate-300 hover:bg-slate-50'}`}
                >
                  {settings.auto ? 'Auto Routing Enabled' : 'Auto Routing Disabled'}
                </Button>
              </section>

              <section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
                <div className="mb-3">
                  <h3 className="text-sm font-semibold text-slate-900 flex items-center gap-2">
                    <Bot className="h-4 w-4 text-slate-600" />
                    Model Preference
                  </h3>
                  <p className="text-xs text-slate-500">
                    Optional: provider or provider:model (example: `azure` or `azure:gpt-5.2-chat`).
                  </p>
                </div>
                <div className="flex gap-2">
                  <Input
                    value={modelPrefs}
                    onChange={(e) => setModelPrefs(e.target.value)}
                    placeholder="auto"
                    className="rounded-xl border-slate-300 focus-visible:ring-slate-400"
                  />
                  <Button
                    type="button"
                    onClick={saveModelPrefs}
                    size="sm"
                    className="rounded-xl bg-slate-900 text-white hover:bg-slate-800"
                  >
                    Apply
                  </Button>
                </div>
              </section>
            </>
          )}

          {activeTab === 'identity' && (
            <section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm space-y-3">
              <div>
                <h3 className="text-sm font-semibold text-slate-900 flex items-center gap-2">
                  <IdCard className="h-4 w-4 text-slate-600" />
                  Global Identity (`IDENTITY.md`)
                </h3>
                <p className="text-xs text-slate-500">
                  Shared across all chats. Updated: {formatTimestamp(identityQuery.data?.updatedAt)}
                </p>
              </div>
              <Textarea
                value={identityDraft}
                onChange={(event) => setIdentityDraft(event.target.value)}
                placeholder="Describe assistant identity, style, and persistent global rules."
                className="min-h-[300px] rounded-xl border-slate-300 font-mono text-sm"
              />
              <div className="flex justify-end">
                <Button
                  type="button"
                  onClick={() => saveIdentityMutation.mutate(identityDraft)}
                  disabled={saveIdentityMutation.isPending || identityQuery.isLoading}
                  className="rounded-xl bg-slate-900 text-white hover:bg-slate-800"
                >
                  {saveIdentityMutation.isPending ? 'Saving...' : 'Save Identity'}
                </Button>
              </div>
            </section>
          )}

          {activeTab === 'chat' && (
            <section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm space-y-3">
              <div>
                <h3 className="text-sm font-semibold text-slate-900 flex items-center gap-2">
                  <MessageSquareText className="h-4 w-4 text-slate-600" />
                  Chat Memory (`MEMORY.md`)
                </h3>
                <p className="text-xs text-slate-500">
                  {selectedChatId
                    ? `Applies only to this chat. Updated: ${formatTimestamp(chatMemoryQuery.data?.updatedAt)}`
                    : 'Select a chat to edit chat memory.'}
                </p>
              </div>

              {selectedChatId ? (
                <>
                  <Textarea
                    value={chatMemoryDraft}
                    onChange={(event) => setChatMemoryDraft(event.target.value)}
                    placeholder="Store thread-specific facts and decisions."
                    className="min-h-[300px] rounded-xl border-slate-300 font-mono text-sm"
                  />
                  <div className="flex justify-end">
                    <Button
                      type="button"
                      onClick={() => saveChatMemoryMutation.mutate(chatMemoryDraft)}
                      disabled={saveChatMemoryMutation.isPending || chatMemoryQuery.isLoading}
                      className="rounded-xl bg-slate-900 text-white hover:bg-slate-800"
                    >
                      {saveChatMemoryMutation.isPending ? 'Saving...' : 'Save Chat Memory'}
                    </Button>
                  </div>
                </>
              ) : (
                <div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-center text-sm text-slate-500">
                  Create or select a chat, then reopen this tab to edit chat memory.
                </div>
              )}
            </section>
          )}
        </div>
      </DialogContent>
    </Dialog>
  );
}

input.tsx

Path: starbot_cloud/src/components/ui/input.tsx

Size: 815 bytes

import * as React from "react"
import { cn } from "@/lib/utils"

export interface InputProps
  extends React.InputHTMLAttributes<HTMLInputElement> {}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, type, ...props }, ref) => {
    return (
      <input
        type={type}
        className={cn(
          "flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        ref={ref}
        {...props}
      />
    )
  }
)
Input.displayName = "Input"

export { Input }

sonner.tsx

Path: starbot_cloud/src/components/ui/sonner.tsx

Size: 854 bytes

"use client"

import { Toaster as Sonner } from "sonner"

type ToasterProps = React.ComponentProps<typeof Sonner>

const Toaster = ({ ...props }: ToasterProps) => {
  return (
    <Sonner
      theme="system"
      className="toaster group"
      position="top-right"
      richColors
      duration={3000}
      toastOptions={{
        classNames: {
          toast:
            "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
          description: "group-[.toast]:text-muted-foreground",
          actionButton:
            "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
          cancelButton:
            "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
        },
      }}
      {...props}
    />
  )
}

export { Toaster }

scroll-area.tsx

Path: starbot_cloud/src/components/ui/scroll-area.tsx

Size: 391 bytes

import * as React from "react"
import { cn } from "@/lib/utils"

const ScrollArea = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("relative overflow-auto", className)}
    {...props}
  >
    {children}
  </div>
))
ScrollArea.displayName = "ScrollArea"

export { ScrollArea }

tooltip.tsx

Path: starbot_cloud/src/components/ui/tooltip.tsx

Size: 1158 bytes

"use client"

import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"

const TooltipProvider = TooltipPrimitive.Provider

const Tooltip = TooltipPrimitive.Root

const TooltipTrigger = TooltipPrimitive.Trigger

const TooltipContent = React.forwardRef<
  React.ElementRef<typeof TooltipPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
  <TooltipPrimitive.Content
    ref={ref}
    sideOffset={sideOffset}
    className={cn(
      "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
      className
    )}
    {...props}
  />
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

skeleton.tsx

Path: starbot_cloud/src/components/ui/skeleton.tsx

Size: 261 bytes

import { cn } from "@/lib/utils"

function Skeleton({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cn("animate-pulse rounded-md bg-muted", className)}
      {...props}
    />
  )
}

export { Skeleton }

button.tsx

Path: starbot_cloud/src/components/ui/button.tsx

Size: 1550 bytes

import * as React from "react"
import { cn } from "@/lib/utils"

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
  size?: 'default' | 'sm' | 'lg' | 'icon'
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = 'default', size = 'default', ...props }, ref) => {
    const variants = {
      default: "bg-slate-900 text-slate-50 hover:bg-slate-900/90",
      destructive: "bg-red-500 text-slate-50 hover:bg-red-500/90",
      outline: "border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900",
      secondary: "bg-slate-100 text-slate-900 hover:bg-slate-100/80",
      ghost: "hover:bg-slate-100 hover:text-slate-900",
      link: "text-slate-900 underline-offset-4 hover:underline",
    }
    const sizes = {
      default: "h-10 px-4 py-2",
      sm: "h-9 rounded-md px-3",
      lg: "h-11 rounded-md px-8",
      icon: "h-10 w-10",
    }
    return (
      <button
        className={cn(
          "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
          variants[variant],
          sizes[size],
          className
        )}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button }

dropdown-menu.tsx

Path: starbot_cloud/src/components/ui/dropdown-menu.tsx

Size: 7308 bytes

"use client"

import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"

const DropdownMenu = DropdownMenuPrimitive.Root

const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger

const DropdownMenuGroup = DropdownMenuPrimitive.Group

const DropdownMenuPortal = DropdownMenuPrimitive.Portal

const DropdownMenuSub = DropdownMenuPrimitive.Sub

const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup

const DropdownMenuSubTrigger = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
    inset?: boolean
  }
>(({ className, inset, children, ...props }, ref) => (
  <DropdownMenuPrimitive.SubTrigger
    ref={ref}
    className={cn(
      "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
      inset && "pl-8",
      className
    )}
    {...props}
  >
    {children}
    <ChevronRight className="ml-auto h-4 w-4" />
  </DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
  DropdownMenuPrimitive.SubTrigger.displayName

const DropdownMenuSubContent = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
  <DropdownMenuPrimitive.SubContent
    ref={ref}
    className={cn(
      "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
      className
    )}
    {...props}
  />
))
DropdownMenuSubContent.displayName =
  DropdownMenuPrimitive.SubContent.displayName

const DropdownMenuContent = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
  <DropdownMenuPrimitive.Portal>
    <DropdownMenuPrimitive.Content
      ref={ref}
      sideOffset={sideOffset}
      className={cn(
        "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        className
      )}
      {...props}
    />
  </DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName

const DropdownMenuItem = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
    inset?: boolean
  }
>(({ className, inset, ...props }, ref) => (
  <DropdownMenuPrimitive.Item
    ref={ref}
    className={cn(
      "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
      inset && "pl-8",
      className
    )}
    {...props}
  />
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName

const DropdownMenuCheckboxItem = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
  <DropdownMenuPrimitive.CheckboxItem
    ref={ref}
    className={cn(
      "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
      className
    )}
    checked={checked}
    {...props}
  >
    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
      <DropdownMenuPrimitive.ItemIndicator>
        <Check className="h-4 w-4" />
      </DropdownMenuPrimitive.ItemIndicator>
    </span>
    {children}
  </DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
  DropdownMenuPrimitive.CheckboxItem.displayName

const DropdownMenuRadioItem = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
  <DropdownMenuPrimitive.RadioItem
    ref={ref}
    className={cn(
      "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
      className
    )}
    {...props}
  >
    <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
      <DropdownMenuPrimitive.ItemIndicator>
        <Circle className="h-2 w-2 fill-current" />
      </DropdownMenuPrimitive.ItemIndicator>
    </span>
    {children}
  </DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName

const DropdownMenuLabel = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Label>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
    inset?: boolean
  }
>(({ className, inset, ...props }, ref) => (
  <DropdownMenuPrimitive.Label
    ref={ref}
    className={cn(
      "px-2 py-1.5 text-sm font-semibold",
      inset && "pl-8",
      className
    )}
    {...props}
  />
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName

const DropdownMenuSeparator = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
  <DropdownMenuPrimitive.Separator
    ref={ref}
    className={cn("-mx-1 my-1 h-px bg-muted", className)}
    {...props}
  />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName

const DropdownMenuShortcut = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
  return (
    <span
      className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
      {...props}
    />
  )
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"

export {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuCheckboxItem,
  DropdownMenuRadioItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuGroup,
  DropdownMenuPortal,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuRadioGroup,
}

textarea.tsx

Path: starbot_cloud/src/components/ui/textarea.tsx

Size: 763 bytes

import * as React from "react"
import { cn } from "@/lib/utils"

export interface TextareaProps
  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
  ({ className, ...props }, ref) => {
    return (
      <textarea
        className={cn(
          "flex min-h-[80px] w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        ref={ref}
        {...props}
      />
    )
  }
)
Textarea.displayName = "Textarea"

export { Textarea }

dialog.tsx

Path: starbot_cloud/src/components/ui/dialog.tsx

Size: 3844 bytes

"use client"

import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"

const Dialog = DialogPrimitive.Root

const DialogTrigger = DialogPrimitive.Trigger

const DialogPortal = DialogPrimitive.Portal

const DialogClose = DialogPrimitive.Close

const DialogOverlay = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Overlay
    ref={ref}
    className={cn(
      "fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
      className
    )}
    {...props}
  />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
        <X className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName

const DialogHeader = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col space-y-1.5 text-center sm:text-left",
      className
    )}
    {...props}
  />
)
DialogHeader.displayName = "DialogHeader"

const DialogFooter = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
      className
    )}
    {...props}
  />
)
DialogFooter.displayName = "DialogFooter"

const DialogTitle = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Title
    ref={ref}
    className={cn(
      "text-lg font-semibold leading-none tracking-tight",
      className
    )}
    {...props}
  />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName

const DialogDescription = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Description
    ref={ref}
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName

export {
  Dialog,
  DialogPortal,
  DialogOverlay,
  DialogClose,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogDescription,
}

card.tsx

Path: starbot_cloud/src/components/ui/card.tsx

Size: 1881 bytes

import * as React from "react"
import { cn } from "@/lib/utils"

const Card = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn(
      "rounded-lg border border-slate-200 bg-white text-slate-950 shadow-sm",
      className
    )}
    {...props}
  />
))
Card.displayName = "Card"

const CardHeader = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex flex-col space-y-1.5 p-6", className)}
    {...props}
  />
))
CardHeader.displayName = "CardHeader"

const CardTitle = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
  <h3
    ref={ref}
    className={cn(
      "text-2xl font-semibold leading-none tracking-tight",
      className
    )}
    {...props}
  />
))
CardTitle.displayName = "CardTitle"

const CardDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
  <p
    ref={ref}
    className={cn("text-sm text-slate-500", className)}
    {...props}
  />
))
CardDescription.displayName = "CardDescription"

const CardContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"

const CardFooter = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex items-center p-6 pt-0", className)}
    {...props}
  />
))
CardFooter.displayName = "CardFooter"

export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

query-provider.tsx

Path: starbot_cloud/src/components/providers/query-provider.tsx

Size: 505 bytes

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export default function QueryProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
        refetchOnWindowFocus: false,
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

toast-provider.tsx

Path: starbot_cloud/src/components/providers/toast-provider.tsx

Size: 129 bytes

"use client"

import { Toaster } from "@/components/ui/sonner"

export default function ToastProvider() {
  return <Toaster />
}

admin-shell.tsx

Path: starbot_cloud/src/components/admin/admin-shell.tsx

Size: 4478 bytes

'use client';

import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useSyncExternalStore } from 'react';
import { Boxes, ExternalLink, IdCard, LayoutDashboard, LogOut, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
  AUTH_CHANGED_EVENT,
  AUTH_SESSION_KEY,
  clearAuthSession,
  clearServerSession,
  readAuthSession,
} from '@/lib/auth-session';

const CHAT_APP_URL = process.env.NEXT_PUBLIC_CHAT_APP_URL || 'https://starbot.cloud';

const NAV_ITEMS = [
  {
    href: '/admin',
    label: 'Overview',
    icon: LayoutDashboard,
  },
  {
    href: '/admin/providers',
    label: 'Providers',
    icon: Boxes,
  },
  {
    href: '/admin/identity',
    label: 'Identity',
    icon: IdCard,
  },
];

function subscribeAuthSession(onStoreChange: () => void) {
  if (typeof window === 'undefined') return () => {};

  const onStorage = (event: StorageEvent) => {
    if (!event.key || event.key === AUTH_SESSION_KEY) {
      onStoreChange();
    }
  };

  window.addEventListener('storage', onStorage);
  window.addEventListener(AUTH_CHANGED_EVENT, onStoreChange);
  return () => {
    window.removeEventListener('storage', onStorage);
    window.removeEventListener(AUTH_CHANGED_EVENT, onStoreChange);
  };
}

export function AdminShell({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  const router = useRouter();
  const session = useSyncExternalStore(subscribeAuthSession, readAuthSession, () => null);

  const handleSignOut = async () => {
    await clearServerSession();
    clearAuthSession();
    router.push('/login');
  };

  return (
    <div className="min-h-screen bg-[radial-gradient(circle_at_top_right,rgba(14,165,233,0.14),transparent_45%),linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)]">
      <div className="mx-auto flex min-h-screen w-full max-w-[1200px] flex-col gap-4 p-4 md:flex-row md:gap-6 md:p-6">
        <aside className="w-full rounded-3xl border border-slate-200/90 bg-white/90 p-4 shadow-[0_18px_45px_rgba(15,23,42,0.08)] md:w-72 md:p-5">
          <div className="mb-5 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
            <p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Starbot Console</p>
            <h1 className="mt-1 flex items-center gap-2 text-lg font-semibold text-slate-900">
              <Shield className="h-5 w-5 text-slate-700" />
              Admin
            </h1>
            <p className="mt-2 text-xs text-slate-600">
              Signed in as {session?.name || session?.email || 'Admin'}
            </p>
          </div>

          <nav className="space-y-1.5">
            {NAV_ITEMS.map((item) => {
              const isActive = pathname === item.href;
              return (
                <Link
                  key={item.href}
                  href={item.href}
                  className={cn(
                    'flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition',
                    isActive
                      ? 'border-slate-900 bg-slate-900 text-white'
                      : 'border-slate-200 bg-white text-slate-700 hover:bg-slate-50'
                  )}
                >
                  <item.icon className="h-4 w-4" />
                  {item.label}
                </Link>
              );
            })}
          </nav>

          <div className="mt-6 space-y-2">
            <Button
              type="button"
              variant="outline"
              className="w-full justify-start rounded-xl border-slate-300 text-slate-700 hover:bg-slate-100"
              onClick={() => {
                window.location.href = CHAT_APP_URL;
              }}
            >
              <ExternalLink className="mr-2 h-4 w-4" />
              Open Chat App
            </Button>
            <Button
              type="button"
              variant="outline"
              className="w-full justify-start rounded-xl border-red-300 text-red-600 hover:bg-red-50 hover:text-red-700"
              onClick={handleSignOut}
            >
              <LogOut className="mr-2 h-4 w-4" />
              Sign out
            </Button>
          </div>
        </aside>

        <main className="flex-1 rounded-3xl border border-slate-200/90 bg-white/90 p-4 shadow-[0_18px_45px_rgba(15,23,42,0.08)] md:p-6">
          {children}
        </main>
      </div>
    </div>
  );
}

message-skeleton.tsx

Path: starbot_cloud/src/components/chat/message-skeleton.tsx

Size: 793 bytes

import { Skeleton } from "@/components/ui/skeleton"

export function MessageSkeleton() {
  return (
    <div className="space-y-4 max-w-3xl mx-auto p-4">
      {/* Assistant message skeleton */}
      <div className="flex flex-col gap-1 items-start">
        <Skeleton className="h-3 w-16" />
        <Skeleton className="h-20 w-[70%] rounded-lg" />
      </div>

      {/* User message skeleton */}
      <div className="flex flex-col gap-1 items-end">
        <Skeleton className="h-3 w-12" />
        <Skeleton className="h-16 w-[60%] rounded-lg" />
      </div>

      {/* Assistant message skeleton */}
      <div className="flex flex-col gap-1 items-start">
        <Skeleton className="h-3 w-16" />
        <Skeleton className="h-24 w-[75%] rounded-lg" />
      </div>
    </div>
  )
}

chat-view.tsx

Path: starbot_cloud/src/components/chat/chat-view.tsx

Size: 7637 bytes

import { useChatStream } from '@/hooks/use-chat-stream';
import { useUIStore } from '@/store/ui-store';
import { MessageList } from './message-list';
import { ChatInput } from './chat-input';
import { messagesApi } from '@/lib/api/messages';
import { chatsApi } from '@/lib/api/chats';
import { projectsApi } from '@/lib/api/projects';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { MessageSquare, Plus } from 'lucide-react';
import { Message } from '@/lib/types';
import { toast } from 'sonner';
import { MessageSkeleton } from './message-skeleton';
import { Button } from '@/components/ui/button';
import { ApiError } from '@/lib/api';
import { readAuthSession } from '@/lib/auth-session';

export function ChatView() {
  const { selectedChatId, settings, setDraftInput, setSelectedChatId } = useUIStore();
  const { messages, isLoading, status, startStream } = useChatStream(selectedChatId);
  const queryClient = useQueryClient();

  const session = readAuthSession();
  const userEmail = session?.email;

  const { data: projects } = useQuery({
    queryKey: ['projects', userEmail],
    queryFn: () => userEmail ? projectsApi.list(userEmail) : Promise.resolve([]),
    enabled: !!userEmail,
  });
  const currentProjectId = projects?.[0]?.id;

  const createChatMutation = useMutation({
    mutationFn: async () => {
      if (!userEmail) throw new Error('Not logged in');

      let projectId = currentProjectId;

      if (!projectId) {
        const project = await projectsApi.create({ name: 'My Project', email: userEmail });
        projectId = project.id;
      }

      return chatsApi.create(projectId, { title: 'New Chat', clientSource: 'webgui' });
    },
    onSuccess: (newChat) => {
      const projectIdForCache = newChat.projectId || currentProjectId;
      queryClient.invalidateQueries({ queryKey: ['projects'] });
      if (projectIdForCache) {
        queryClient.invalidateQueries({ queryKey: ['chats', projectIdForCache] });
      }
      setSelectedChatId(newChat.id);
    },
    onError: (error) => {
      const message = error instanceof ApiError
        ? error.message
        : error instanceof Error
          ? error.message
          : 'Failed to create chat';
      toast.error(message);
    },
  });

  const sendMutation = useMutation({
    mutationFn: (content: string) => {
      if (!selectedChatId) throw new Error('No chat selected');
      return messagesApi.send(selectedChatId, content, 'user');
    },
    onMutate: async (content) => {
        // Optimistic update: add user message to UI immediately
        if (!selectedChatId) return;

        await queryClient.cancelQueries({ queryKey: ['messages', selectedChatId] });

        const previousMessages = queryClient.getQueryData(['messages', selectedChatId]);

        queryClient.setQueryData(['messages', selectedChatId], (old: Message[] = []) => [
            ...old,
            {
                id: 'temp-user',
                chatId: selectedChatId,
                role: 'user',
                content,
                createdAt: new Date().toISOString(),
            },
        ]);

        return { previousMessages };
    },
    onSuccess: () => {
        // After message is sent, trigger the assistant response
        if (selectedChatId) {
            startStream(settings);
        }
    },
    onError: (err, content, context) => {
        if (selectedChatId && context?.previousMessages) {
            queryClient.setQueryData(['messages', selectedChatId], context.previousMessages);
        }
    }
  });

  const deleteMutation = useMutation({
    mutationFn: (messageId: string) => messagesApi.delete(messageId),
    onMutate: async (messageId) => {
      if (!selectedChatId) return;

      await queryClient.cancelQueries({ queryKey: ['messages', selectedChatId] });
      const previousMessages = queryClient.getQueryData(['messages', selectedChatId]);

      queryClient.setQueryData(['messages', selectedChatId], (old: Message[] = []) =>
        old.filter((msg) => msg.id !== messageId)
      );

      return { previousMessages };
    },
    onError: (err, messageId, context) => {
      if (selectedChatId && context?.previousMessages) {
        queryClient.setQueryData(['messages', selectedChatId], context.previousMessages);
      }
      toast.error('Failed to delete message');
    },
    onSuccess: () => {
      toast.success('Message deleted');
    },
  });

  const handleSend = (content: string) => {
    if (selectedChatId) {
      sendMutation.mutate(content);
    }
  };

  const handleEditMessage = async (message: Message) => {
    if (!selectedChatId) return;

    try {
      // Set the draft input to the message content
      setDraftInput(message.content);

      // Delete this message and all subsequent messages
      await messagesApi.deleteAfter(selectedChatId, message.id);

      // Invalidate and refetch messages
      queryClient.invalidateQueries({ queryKey: ['messages', selectedChatId] });

      toast.success('Message ready to edit');
    } catch (error) {
      toast.error('Failed to edit message');
    }
  };

  const handleDeleteMessage = (messageId: string) => {
    deleteMutation.mutate(messageId);
  };

  const handleRegenerateMessage = async (messageId: string) => {
    if (!selectedChatId) return;

    try {
      // Delete this message and all subsequent messages
      await messagesApi.deleteAfter(selectedChatId, messageId);

      // Invalidate queries to refresh the message list
      await queryClient.invalidateQueries({ queryKey: ['messages', selectedChatId] });

      // Trigger a new stream for the assistant response
      startStream(settings);

      toast.success('Regenerating response');
    } catch (error) {
      toast.error('Failed to regenerate message');
    }
  };

  if (!selectedChatId) {
    return (
      <div className="flex-1 flex items-center justify-center p-6">
        <div className="w-full max-w-xl rounded-3xl border border-slate-200/80 bg-white/80 p-8 shadow-[0_20px_50px_rgba(15,23,42,0.08)] text-center space-y-5">
          <div className="flex justify-center">
            <div className="rounded-2xl bg-gradient-to-br from-slate-900 to-slate-700 p-5 shadow-lg">
              <MessageSquare className="h-10 w-10 text-slate-50" />
            </div>
          </div>
          <div className="space-y-2">
            <h3 className="text-xl font-semibold text-slate-900">Start A Conversation</h3>
            <p className="text-sm text-slate-600 max-w-sm mx-auto">
              Create a chat to begin. You can switch mode, speed, and model preferences in Settings.
            </p>
          </div>
          <Button
            onClick={() => createChatMutation.mutate()}
            disabled={createChatMutation.isPending}
            className="bg-slate-900 text-slate-50 hover:bg-slate-800"
          >
            <Plus className="h-4 w-4 mr-2" />
            {createChatMutation.isPending ? 'Creating...' : 'New Chat'}
          </Button>
        </div>
      </div>
    );
  }

  if (isLoading) {
    return (
      <div className="flex-1 flex flex-col h-full overflow-hidden p-3 md:p-4">
        <MessageSkeleton />
      </div>
    );
  }

  return (
    <div className="flex-1 flex flex-col h-full overflow-hidden relative">
      <MessageList
        messages={messages || []}
        status={status}
        onEditMessage={handleEditMessage}
        onDeleteMessage={handleDeleteMessage}
        onRegenerateMessage={handleRegenerateMessage}
      />
      <ChatInput onSend={handleSend} disabled={!!status || sendMutation.isPending} />
    </div>
  );
}

markdown-content.tsx

Path: starbot_cloud/src/components/chat/markdown-content.tsx

Size: 2312 bytes

"use client"

import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm"
import rehypeHighlight from "rehype-highlight"
import rehypeRaw from "rehype-raw"
import { CodeBlock } from "./code-block"
import { cn } from "@/lib/utils"

interface MarkdownContentProps {
  content: string
  className?: string
}

export function MarkdownContent({ content, className }: MarkdownContentProps) {
  return (
    <div
      className={cn(
        "prose prose-sm dark:prose-invert max-w-none",
        "prose-p:leading-relaxed prose-pre:p-0 prose-pre:bg-transparent",
        "prose-headings:font-semibold prose-headings:tracking-tight",
        "prose-a:text-primary prose-a:no-underline hover:prose-a:underline",
        "prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:font-mono prose-code:text-sm prose-code:before:content-[''] prose-code:after:content-['']",
        "prose-img:rounded-lg prose-img:shadow-md",
        "prose-hr:border-border",
        "prose-blockquote:border-l-primary prose-blockquote:font-normal prose-blockquote:not-italic",
        "prose-table:border-border prose-th:border-border prose-td:border-border",
        className
      )}
    >
      <ReactMarkdown
        remarkPlugins={[remarkGfm]}
        rehypePlugins={[rehypeHighlight, rehypeRaw]}
        components={{
        code: (props: any) => {
          const { node, inline, className, children, ...rest } = props
          const match = /language-(\w+)/.exec(className || "")
          const language = match ? match[1] : undefined

          if (!inline && language) {
            return (
              <CodeBlock language={language}>
                {String(children).replace(/\n$/, "")}
              </CodeBlock>
            )
          }

          return (
            <code className={className} {...rest}>
              {children}
            </code>
          )
        },
        a: (props: any) => {
          const { node, children, href, ...rest } = props
          return (
            <a
              href={href}
              target="_blank"
              rel="noopener noreferrer"
              {...rest}
            >
              {children}
            </a>
          )
        },
        }}
      >
        {content}
      </ReactMarkdown>
    </div>
  )
}

code-block.tsx

Path: starbot_cloud/src/components/chat/code-block.tsx

Size: 1525 bytes

"use client"

import { useState } from "react"
import { Check, Copy } from "lucide-react"
import { cn } from "@/lib/utils"

interface CodeBlockProps {
  children: string
  language?: string
  className?: string
}

export function CodeBlock({ children, language, className }: CodeBlockProps) {
  const [copied, setCopied] = useState(false)

  const handleCopy = async () => {
    await navigator.clipboard.writeText(children)
    setCopied(true)
    setTimeout(() => setCopied(false), 2000)
  }

  return (
    <div className="group relative my-4">
      {language && (
        <div className="flex items-center justify-between rounded-t-lg bg-muted px-4 py-2 font-mono text-xs text-muted-foreground">
          <span>{language}</span>
        </div>
      )}
      <div className="relative">
        <pre
          className={cn(
            "overflow-x-auto rounded-b-lg bg-muted p-4",
            !language && "rounded-lg",
            className
          )}
        >
          <code className={language ? `language-${language}` : ""}>
            {children}
          </code>
        </pre>
        <button
          onClick={handleCopy}
          className="absolute right-2 top-2 rounded-md bg-background p-2 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100"
          aria-label="Copy code"
        >
          {copied ? (
            <Check className="h-4 w-4 text-green-500" />
          ) : (
            <Copy className="h-4 w-4" />
          )}
        </button>
      </div>
    </div>
  )
}

message-list.tsx

Path: starbot_cloud/src/components/chat/message-list.tsx

Size: 2693 bytes

import { Message } from '@/lib/types';
import { cn } from '@/lib/utils';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useEffect, useRef } from 'react';
import { MarkdownContent } from './markdown-content';
import { MessageActions } from './message-actions';

interface MessageListProps {
  messages: Message[];
  status?: string;
  onEditMessage?: (message: Message) => void;
  onDeleteMessage?: (messageId: string) => void;
  onRegenerateMessage?: (messageId: string) => void;
}

export function MessageList({
  messages,
  status,
  onEditMessage,
  onDeleteMessage,
  onRegenerateMessage
}: MessageListProps) {
  const scrollRef = useRef<HTMLDivElement>(null);
  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages, status]);

  return (
    <ScrollArea className="flex-1 px-4 py-5 md:px-6" ref={scrollRef}>
      <div className="space-y-4 max-w-4xl mx-auto" role="log" aria-live="polite">
        {messages.map((msg, idx) => (
          <div
            key={msg.id || idx}
            className={cn(
              "group flex flex-col gap-1.5",
              msg.role === 'user' ? "items-end" : "items-start"
            )}
          >
            <div className={cn(
               "flex items-center gap-2 text-xs text-slate-500",
               msg.role === 'user' ? "flex-row-reverse text-right" : "text-left"
            )}>
              <span>{msg.role === 'user' ? 'You' : 'Starbot'}</span>
              <MessageActions
                message={msg}
                onEdit={onEditMessage}
                onDelete={onDeleteMessage}
                onRegenerate={onRegenerateMessage}
              />
            </div>
            <div
              className={cn(
                "rounded-2xl px-4 py-3 max-w-[92%] sm:max-w-[85%] md:max-w-[78%] shadow-sm",
                msg.role === 'user'
                  ? "bg-slate-900 text-slate-50"
                  : "bg-white border border-slate-200 text-slate-900"
              )}
            >
              <MarkdownContent content={msg.content} />
            </div>
            {msg.role === 'tool' && (
                <div className="text-xs text-slate-600 font-mono bg-slate-100 p-2 rounded-lg">
                    Tool Output: {msg.content}
                </div>
            )}
          </div>
        ))}
        {status && (
          <div className="flex items-center gap-2 text-sm text-slate-500 italic pl-1">
            <span>{status}</span>
             <span className="animate-pulse">...</span>
          </div>
        )}
        <div ref={bottomRef} />
      </div>
    </ScrollArea>
  );
}

chat-input.tsx

Path: starbot_cloud/src/components/chat/chat-input.tsx

Size: 2016 bytes

import { KeyboardEvent, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Send } from 'lucide-react';
import { useUIStore } from '@/store/ui-store';

interface ChatInputProps {
  onSend: (content: string) => void;
  disabled?: boolean;
}

export function ChatInput({ onSend, disabled }: ChatInputProps) {
  const { draftInput, setDraftInput } = useUIStore();
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // Focus textarea when component mounts or becomes enabled
  useEffect(() => {
    if (!disabled && textareaRef.current) {
      textareaRef.current.focus();
    }
  }, [disabled]);

  const handleSend = () => {
    if (draftInput.trim() && !disabled) {
      onSend(draftInput);
      setDraftInput('');
      // Re-focus after sending
      setTimeout(() => {
        textareaRef.current?.focus();
      }, 0);
    }
  };

  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="border-t border-slate-200/80 bg-white/85 px-4 py-4 backdrop-blur">
      <div className="max-w-4xl mx-auto flex gap-2 items-end">
        <Textarea
          ref={textareaRef}
          value={draftInput}
          onChange={(e) => setDraftInput(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Ask Starbot anything..."
          className="min-h-[56px] max-h-[220px] rounded-2xl border-slate-300 bg-white shadow-sm focus-visible:ring-slate-400"
          disabled={disabled}
          aria-label="Message input"
        />
        <Button
          onClick={handleSend}
          disabled={disabled || !draftInput.trim()}
          size="icon"
          className="h-11 w-11 rounded-2xl bg-slate-900 text-slate-50 hover:bg-slate-800"
          aria-label="Send message"
        >
          <Send className="h-4 w-4" />
        </Button>
      </div>
    </div>
  );
}

message-actions.tsx

Path: starbot_cloud/src/components/chat/message-actions.tsx

Size: 2751 bytes

"use client"

import { Copy, MoreVertical, Edit, Trash2, RotateCcw } from "lucide-react"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { toast } from "sonner"
import { Message } from "@/lib/types"

interface MessageActionsProps {
  message: Message
  onEdit?: (message: Message) => void
  onDelete?: (messageId: string) => void
  onRegenerate?: (messageId: string) => void
}

export function MessageActions({
  message,
  onEdit,
  onDelete,
  onRegenerate,
}: MessageActionsProps) {
  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(message.content)
      toast.success("Copied to clipboard")
    } catch (error) {
      toast.error("Failed to copy")
    }
  }

  const handleEdit = () => {
    if (onEdit) {
      onEdit(message)
    }
  }

  const handleDelete = () => {
    if (onDelete) {
      onDelete(message.id)
    }
  }

  const handleRegenerate = () => {
    if (onRegenerate) {
      onRegenerate(message.id)
    }
  }

  return (
    <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
      <button
        onClick={handleCopy}
        className="rounded-md p-1 hover:bg-accent transition-colors"
        aria-label="Copy message"
      >
        <Copy className="h-3.5 w-3.5" />
      </button>

      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <button
            className="rounded-md p-1 hover:bg-accent transition-colors"
            aria-label="More actions"
          >
            <MoreVertical className="h-3.5 w-3.5" />
          </button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuItem onClick={handleCopy}>
            <Copy className="mr-2 h-4 w-4" />
            Copy
          </DropdownMenuItem>

          {message.role === "user" && onEdit && (
            <DropdownMenuItem onClick={handleEdit}>
              <Edit className="mr-2 h-4 w-4" />
              Edit
            </DropdownMenuItem>
          )}

          {message.role === "assistant" && onRegenerate && (
            <DropdownMenuItem onClick={handleRegenerate}>
              <RotateCcw className="mr-2 h-4 w-4" />
              Regenerate
            </DropdownMenuItem>
          )}

          <DropdownMenuSeparator />

          {onDelete && (
            <DropdownMenuItem
              onClick={handleDelete}
              className="text-destructive focus:text-destructive"
            >
              <Trash2 className="mr-2 h-4 w-4" />
              Delete
            </DropdownMenuItem>
          )}
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  )
}

config.ts

Path: starbot_cloud/src/lib/config.ts

Size: 1017 bytes

function resolveApiBaseUrl() {
  const explicit = process.env.NEXT_PUBLIC_API_URL?.trim();
  if (typeof window !== 'undefined') {
    const host = window.location.hostname;

    if (explicit) {
      // Keep same-origin behavior when the configured URL points at a different host
      // (for example console.starbot.cloud using an old starbot.cloud env value).
      if (explicit.startsWith('/')) {
        return explicit;
      }

      try {
        const parsed = new URL(explicit);
        if (parsed.hostname !== host) {
          return '/v1';
        }
      } catch {
        // Fall through to explicit value if URL parsing fails.
      }

      return explicit;
    }

    // In production we want browser calls to stay same-origin so Caddy can route /v1.
    if (host === 'localhost' || host === '127.0.0.1') {
      return 'http://localhost:3737/v1';
    }
    return '/v1';
  }

  if (explicit) return explicit;
  return 'http://localhost:3737/v1';
}

export const API_BASE_URL = resolveApiBaseUrl();

utils.ts

Path: starbot_cloud/src/lib/utils.ts

Size: 166 bytes

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

api.ts

Path: starbot_cloud/src/lib/api.ts

Size: 2495 bytes

import { API_BASE_URL } from './config';
import { z } from 'zod';
import { readAuthSession } from './auth-session';

export class ApiError extends Error {
  constructor(public status: number, public message: string, public data?: any) {
    super(message);
    this.name = 'ApiError';
  }
}

async function fetchApi<T>(path: string, options: RequestInit = {}, schema?: z.ZodSchema<T>): Promise<T> {
  const headers = new Headers(options.headers);

  // Only set Content-Type for requests with a body (not DELETE, etc.)
  if (!headers.has('Content-Type') && !(options.body instanceof FormData) && options.body) {
    headers.set('Content-Type', 'application/json');
  }

  // Add authorization header if we have a token
  const session = readAuthSession();
  if (session?.token) {
    headers.set('Authorization', `Bearer ${session.token}`);
  }

  const url = `${API_BASE_URL}${path}`;
  const response = await fetch(url, { ...options, headers });

  if (!response.ok) {
    let errorMessage = `API Error: ${response.status} ${response.statusText}`;
    let errorData;
    try {
      errorData = await response.json();
      if (errorData && errorData.message) {
        errorMessage = errorData.message;
      }
    } catch (e) {
      // ignore
    }
    throw new ApiError(response.status, errorMessage, errorData);
  }

  if (response.status === 204) {
    return {} as T;
  }

  let data;
  try {
    data = await response.json();
  } catch (e) {
      throw new ApiError(response.status, 'Invalid JSON response');
  }

  if (schema) {
      const result = schema.safeParse(data);
      if (!result.success) {
          console.error('Validation Error:', result.error);
          throw new ApiError(response.status, 'Response validation failed');
      }
      return result.data;
  }

  return data as T;
}

export const api = {
  get: <T>(path: string, schema?: z.ZodSchema<T>) => fetchApi<T>(path, { method: 'GET' }, schema),
  post: <T>(path: string, body: any, schema?: z.ZodSchema<T>) => {
    console.log('[API POST]', { path, body });
    return fetchApi<T>(path, { method: 'POST', body: JSON.stringify(body) }, schema);
  },
  put: <T>(path: string, body: any, schema?: z.ZodSchema<T>) => fetchApi<T>(path, { method: 'PUT', body: JSON.stringify(body) }, schema),
  delete: <T>(path: string) => fetchApi<T>(path, { method: 'DELETE' }),
  patch: <T>(path: string, body: any, schema?: z.ZodSchema<T>) => fetchApi<T>(path, { method: 'PATCH', body: JSON.stringify(body) }, schema),
};

auth-session.ts

Path: starbot_cloud/src/lib/auth-session.ts

Size: 2495 bytes

export interface AuthSession {
  email: string;
  name: string;
  loggedInAt: string;
  token?: string;
  role?: 'admin' | 'user' | 'guest';
}

export const AUTH_SESSION_KEY = 'starbot_session';
export const AUTH_CHANGED_EVENT = 'starbot-auth-changed';

function isBrowser() {
  return typeof window !== 'undefined';
}

export function readAuthSession(): AuthSession | null {
  if (!isBrowser()) return null;
  const raw = localStorage.getItem(AUTH_SESSION_KEY);
  if (!raw) return null;

  try {
    const parsed = JSON.parse(raw) as AuthSession;
    if (!parsed.email || !parsed.name) return null;
    return parsed;
  } catch {
    return null;
  }
}

export function writeAuthSession(session: AuthSession) {
  if (!isBrowser()) return;
  localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session));
  window.dispatchEvent(new Event(AUTH_CHANGED_EVENT));
}

export function clearAuthSession() {
  if (!isBrowser()) return;
  localStorage.removeItem(AUTH_SESSION_KEY);
  window.dispatchEvent(new Event(AUTH_CHANGED_EVENT));
}

export async function syncServerSession(input: {
  email: string;
  name: string;
  adminCode?: string;
  password?: string;
}): Promise<{ role: 'admin' | 'user'; token: string }> {
  if (!isBrowser()) return { role: 'user', token: '' };

  // Validate against backend
  const response = await fetch('/api/auth/session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: input.email,
      name: input.name,
      adminCode: input.adminCode,
      password: input.password,
    }),
  });

  if (!response.ok) {
    throw new Error(response.status === 401 ? 'Invalid credentials' : 'Authentication failed');
  }

  const data = (await response.json()) as { role?: 'admin' | 'user'; token: string };
  return { role: data.role === 'admin' ? 'admin' : 'user', token: data.token || '' };
}

export async function clearServerSession() {
  if (!isBrowser()) return;

  // Get token to invalidate on backend
  const session = readAuthSession();

  try {
    // Invalidate on backend
    if (session?.token) {
      const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3737';
      await fetch(`${API_URL}/v1/auth/logout`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${session.token}`,
        },
      });
    }
  } catch {
    // no-op
  }

  try {
    await fetch('/api/auth/session', {
      method: 'DELETE',
    });
  } catch {
    // no-op
  }
}

types.ts

Path: starbot_cloud/src/lib/types.ts

Size: 1847 bytes

import { z } from 'zod';

export const ProjectSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().optional(),
  description: z.string().optional(),
  createdAt: z.string(),
  updatedAt: z.string().optional(),
});

export const FolderSchema = z.object({
  id: z.string(),
  projectId: z.string().optional(),
  name: z.string(),
  createdAt: z.string().optional(),
  chats: z.array(z.object({
    id: z.string(),
    title: z.string(),
  })).optional(),
});

export const ChatSchema = z.object({
  id: z.string(),
  projectId: z.string().optional(),
  folderId: z.string().optional().nullable(),
  folder: FolderSchema.omit({ chats: true }).optional().nullable(),
  title: z.string(),
  clientSource: z.string().optional(),
  createdAt: z.string(),
  updatedAt: z.string(),
});

export const MessageSchema = z.object({
  id: z.string(),
  chatId: z.string(),
  role: z.enum(['user', 'assistant', 'system', 'tool']),
  content: z.string(),
  createdAt: z.string(),
  metadata: z.record(z.string(), z.any()).optional(),
});

export const SettingsSchema = z.object({
  mode: z.enum(['quick', 'standard', 'deep']).default('standard'),
  auto: z.boolean().default(true),
  speed: z.boolean().default(false), // true = fast mode, false = quality mode
  model_prefs: z.string().optional(),
});

export type Project = z.infer<typeof ProjectSchema>;
export type Folder = z.infer<typeof FolderSchema>;
export type Chat = z.infer<typeof ChatSchema>;
export type Message = z.infer<typeof MessageSchema>;
export type Settings = z.infer<typeof SettingsSchema>;

export interface CreateChatRequest {
    title?: string;
    projectId?: string;
    folderId?: string;
    workspaceId?: string;
    clientSource?: string;
}

export interface SendMessageRequest {
    content: string;
    chatId: string;
    settings?: Partial<Settings>;
}

admin-cookie.ts

Path: starbot_cloud/src/lib/server/admin-cookie.ts

Size: 3904 bytes

const ADMIN_COOKIE_NAME = 'starbot_admin';
const DEFAULT_TTL_SECONDS = 60 * 60 * 8;

const encoder = new TextEncoder();
const decoder = new TextDecoder();

function toBase64Url(bytes: Uint8Array): string {
  let binary = '';
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }

  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

function fromBase64Url(input: string): Uint8Array | null {
  try {
    const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
    const padded = normalized + '==='.slice((normalized.length + 3) % 4);
    const binary = atob(padded);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) {
      bytes[i] = binary.charCodeAt(i);
    }
    return bytes;
  } catch {
    return null;
  }
}

function getSecret(): string {
  const explicit = String(process.env.ADMIN_COOKIE_SECRET || '').trim();
  if (explicit) return explicit;
  return String(process.env.ADMIN_CONSOLE_CODE || '').trim();
}

function getAllowedAdminEmails(): Set<string> {
  const raw = String(process.env.ADMIN_EMAILS || '').trim();
  if (!raw) return new Set<string>();

  return new Set(
    raw
      .split(',')
      .map((entry) => entry.trim().toLowerCase())
      .filter(Boolean)
  );
}

function isTimingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

async function sign(data: string, secret: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );

  const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
  return toBase64Url(new Uint8Array(signature));
}

export function getAdminCookieName(): string {
  return ADMIN_COOKIE_NAME;
}

export function evaluateAdmin(email: string, providedCode?: string): boolean {
  const normalizedEmail = email.trim().toLowerCase();
  const allowedEmails = getAllowedAdminEmails();
  const isEmailAllowed = allowedEmails.has(normalizedEmail);
  if (!isEmailAllowed) return false;

  const requiredCode = String(process.env.ADMIN_CONSOLE_CODE || '').trim();
  if (!requiredCode) return true;

  return String(providedCode || '').trim() === requiredCode;
}

export async function issueAdminCookieToken(email: string): Promise<string | null> {
  const secret = getSecret();
  if (!secret) return null;

  const payload = {
    email: email.trim().toLowerCase(),
    exp: Math.floor(Date.now() / 1000) + DEFAULT_TTL_SECONDS,
  };
  const encodedPayload = toBase64Url(encoder.encode(JSON.stringify(payload)));
  const signature = await sign(encodedPayload, secret);
  return `${encodedPayload}.${signature}`;
}

export async function verifyAdminCookieToken(token?: string): Promise<boolean> {
  if (!token) return false;

  const secret = getSecret();
  if (!secret) return false;

  const parts = token.split('.');
  if (parts.length !== 2) return false;
  const [encodedPayload, providedSignature] = parts;
  if (!encodedPayload || !providedSignature) return false;

  const expectedSignature = await sign(encodedPayload, secret);
  if (!isTimingSafeEqual(providedSignature, expectedSignature)) return false;

  const decodedBytes = fromBase64Url(encodedPayload);
  if (!decodedBytes) return false;

  try {
    const payload = JSON.parse(decoder.decode(decodedBytes)) as {
      email?: string;
      exp?: number;
    };

    if (!payload.email || !payload.exp) return false;
    if (payload.exp <= Math.floor(Date.now() / 1000)) return false;
    const allowedEmails = getAllowedAdminEmails();
    if (!allowedEmails.has(payload.email.trim().toLowerCase())) return false;
    return true;
  } catch {
    return false;
  }
}

chats.ts

Path: starbot_cloud/src/lib/api/chats.ts

Size: 1440 bytes

import { api } from '../api';
import { Chat, ChatSchema, CreateChatRequest, Message, MessageSchema } from '../types';
import { z } from 'zod';

// API response wrapper schemas
const ChatsResponseSchema = z.object({
  chats: z.array(ChatSchema),
});

const ChatResponseSchema = z.object({
  chat: ChatSchema,
});

const MessagesResponseSchema = z.object({
  messages: z.array(MessageSchema),
});

export const chatsApi = {
  list: async (projectId: string, clientSource?: string) => {
    const url = clientSource
      ? `/projects/${projectId}/chats?clientSource=${clientSource}`
      : `/projects/${projectId}/chats`;
    const response = await api.get(url, ChatsResponseSchema);
    return response.chats;
  },

  get: async (id: string) => {
    const response = await api.get(`/chats/${id}`, ChatResponseSchema);
    return response.chat;
  },

  create: async (projectId: string, data: CreateChatRequest) => {
    const response = await api.post(`/projects/${projectId}/chats`, data, ChatResponseSchema);
    return response.chat;
  },

  update: async (id: string, data: Partial<Chat>) => {
    const response = await api.put(`/chats/${id}`, data, ChatResponseSchema);
    return response.chat;
  },

  delete: (id: string) => api.delete<void>(`/chats/${id}`),

  getMessages: async (chatId: string) => {
    const response = await api.get(`/chats/${chatId}/messages`, MessagesResponseSchema);
    return response.messages;
  },
};

memory.ts

Path: starbot_cloud/src/lib/api/memory.ts

Size: 1177 bytes

import { z } from 'zod';
import { api } from '../api';

const MemorySchema = z.object({
  id: z.string(),
  content: z.string(),
  updatedAt: z.string(),
});

const MemoryEnvelopeSchema = z.object({
  memory: MemorySchema,
});

export type MemoryDocument = z.infer<typeof MemorySchema>;

export const memoryApi = {
  getIdentity: async () => {
    const response = await api.get('/identity', MemoryEnvelopeSchema);
    return response.memory;
  },

  updateIdentity: async (content: string) => {
    const response = await api.put('/identity', { content }, MemoryEnvelopeSchema);
    return response.memory;
  },

  processIdentity: async () => {
    return api.post('/identity/process', {});
  },

  getChatMemory: async (chatId: string) => {
    const response = await api.get(`/chats/${chatId}/memory`, MemoryEnvelopeSchema);
    return response.memory;
  },

  updateChatMemory: async (chatId: string, content: string) => {
    const response = await api.put(`/chats/${chatId}/memory`, { content }, MemoryEnvelopeSchema);
    return response.memory;
  },

  processChatMemory: async (chatId: string) => {
    return api.post(`/chats/${chatId}/memory/process`, {});
  },
};

projects.ts

Path: starbot_cloud/src/lib/api/projects.ts

Size: 1070 bytes

import { api } from '../api';
import { Project, ProjectSchema } from '../types';
import { z } from 'zod';

// API response wrapper schemas
const ProjectsResponseSchema = z.object({
  projects: z.array(ProjectSchema),
});

const ProjectResponseSchema = z.object({
  project: ProjectSchema,
});

export const projectsApi = {
  list: async (email: string) => {
    const response = await api.get(`/projects?email=${encodeURIComponent(email)}`, ProjectsResponseSchema);
    return response.projects;
  },

  get: async (id: string) => {
    const response = await api.get(`/projects/${id}`, ProjectResponseSchema);
    return response.project;
  },

  create: async (data: Partial<Project> & { email?: string }) => {
    const response = await api.post('/projects', data, ProjectResponseSchema);
    return response.project;
  },

  update: async (id: string, data: Partial<Project>) => {
    const response = await api.put(`/projects/${id}`, data, ProjectResponseSchema);
    return response.project;
  },

  delete: (id: string) => api.delete<void>(`/projects/${id}`),
};

messages.ts

Path: starbot_cloud/src/lib/api/messages.ts

Size: 905 bytes

import { api } from '../api';
import { Message, MessageSchema } from '../types';
import { z } from 'zod';

// API response wrapper schema
const MessageResponseSchema = z.object({
  message: MessageSchema,
});

export const messagesApi = {
  send: async (chatId: string, content: string, role: 'user' | 'assistant' | 'system' = 'user') => {
    const response = await api.post(
      `/chats/${chatId}/messages`,
      { role, content },
      MessageResponseSchema
    );
    return response.message;
  },

  update: async (id: string, data: Partial<Message>) => {
    const response = await api.put(`/messages/${id}`, data, MessageResponseSchema);
    return response.message;
  },

  delete: async (id: string) => {
    await api.delete(`/messages/${id}`);
  },

  deleteAfter: async (chatId: string, messageId: string) => {
    await api.delete(`/chats/${chatId}/messages/after/${messageId}`);
  },
};

folders.ts

Path: starbot_cloud/src/lib/api/folders.ts

Size: 1058 bytes

import { api } from '../api';
import { Folder, FolderSchema } from '../types';
import { z } from 'zod';

// API response wrapper schemas
const FoldersResponseSchema = z.object({
  folders: z.array(FolderSchema),
});

const FolderResponseSchema = z.object({
  folder: FolderSchema,
});

export const foldersApi = {
  list: async (projectId: string) => {
    const response = await api.get(`/projects/${projectId}/folders`, FoldersResponseSchema);
    return response.folders;
  },

  get: async (id: string) => {
    const response = await api.get(`/folders/${id}`, FolderResponseSchema);
    return response.folder;
  },

  create: async (projectId: string, data: { name: string }) => {
    const response = await api.post(`/projects/${projectId}/folders`, data, FolderResponseSchema);
    return response.folder;
  },

  update: async (id: string, data: Partial<Folder>) => {
    const response = await api.put(`/folders/${id}`, data, FolderResponseSchema);
    return response.folder;
  },

  delete: (id: string) => api.delete<void>(`/folders/${id}`),
};

globals.css

Path: starbot_cloud/src/app/globals.css

Size: 3426 bytes

@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "highlight.js/styles/github.css" (prefers-color-scheme: light);
@import "highlight.js/styles/github-dark.css" (prefers-color-scheme: dark);

:root {
  --background: 212 46% 96%;
  --foreground: 217 33% 14%;
  --card: 0 0% 100%;
  --card-foreground: 217 33% 14%;
  --popover: 0 0% 100%;
  --popover-foreground: 217 33% 14%;
  --primary: 219 48% 14%;
  --primary-foreground: 210 40% 98%;
  --secondary: 212 35% 92%;
  --secondary-foreground: 217 33% 18%;
  --muted: 210 33% 94%;
  --muted-foreground: 215 16% 38%;
  --accent: 202 83% 92%;
  --accent-foreground: 215 42% 19%;
  --destructive: 0 78% 58%;
  --destructive-foreground: 0 0% 98%;
  --border: 214 27% 84%;
  --input: 214 27% 84%;
  --ring: 201 84% 43%;
  --radius: 0.9rem;
}

@theme inline {
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  --color-card: hsl(var(--card));
  --color-card-foreground: hsl(var(--card-foreground));
  --color-popover: hsl(var(--popover));
  --color-popover-foreground: hsl(var(--popover-foreground));
  --color-primary: hsl(var(--primary));
  --color-primary-foreground: hsl(var(--primary-foreground));
  --color-secondary: hsl(var(--secondary));
  --color-secondary-foreground: hsl(var(--secondary-foreground));
  --color-muted: hsl(var(--muted));
  --color-muted-foreground: hsl(var(--muted-foreground));
  --color-accent: hsl(var(--accent));
  --color-accent-foreground: hsl(var(--accent-foreground));
  --color-destructive: hsl(var(--destructive));
  --color-destructive-foreground: hsl(var(--destructive-foreground));
  --color-border: hsl(var(--border));
  --color-input: hsl(var(--input));
  --color-ring: hsl(var(--ring));
  --font-sans: var(--font-space-grotesk);
  --font-mono: var(--font-ibm-plex-mono);
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
}

@media (prefers-color-scheme: dark) {
  :root {
    --background: 216 31% 9%;
    --foreground: 210 32% 92%;
    --card: 216 31% 11%;
    --card-foreground: 210 32% 92%;
    --popover: 216 31% 11%;
    --popover-foreground: 210 32% 92%;
    --primary: 199 89% 61%;
    --primary-foreground: 216 31% 9%;
    --secondary: 217 23% 18%;
    --secondary-foreground: 210 32% 92%;
    --muted: 217 23% 18%;
    --muted-foreground: 213 14% 66%;
    --accent: 199 54% 20%;
    --accent-foreground: 207 86% 87%;
    --destructive: 0 63% 45%;
    --destructive-foreground: 0 0% 98%;
    --border: 217 17% 27%;
    --input: 217 17% 27%;
    --ring: 199 89% 61%;
  }
}

body {
  background:
    radial-gradient(circle at 12% 8%, rgba(56, 189, 248, 0.18), transparent 35%),
    radial-gradient(circle at 88% 86%, rgba(14, 165, 233, 0.14), transparent 35%),
    hsl(var(--background));
  color: hsl(var(--foreground));
  font-family: var(--font-space-grotesk), sans-serif;
}

@layer base {
  * {
    @apply border-border;
  }

  /* Custom scrollbar */
  ::-webkit-scrollbar {
    width: 6px;
    height: 6px;
  }

  ::-webkit-scrollbar-track {
    background: transparent;
  }

  ::-webkit-scrollbar-thumb {
    background: #cbd5e1;
    border-radius: 3px;
  }

  ::-webkit-scrollbar-thumb:hover {
    background: #94a3b8;
  }

  @media (prefers-color-scheme: dark) {
    ::-webkit-scrollbar-thumb {
      background: #475569;
    }
    ::-webkit-scrollbar-thumb:hover {
      background: #64748b;
    }
  }
}

page.tsx

Path: starbot_cloud/src/app/page.tsx

Size: 3486 bytes

'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Sidebar } from '@/components/sidebar';
import { ChatView } from '@/components/chat/chat-view';
import { SettingsPanel } from '@/components/settings-panel';
import { LogsPanel } from '@/components/logs-panel';
import { AccountMenu } from '@/components/account-menu';
import { useUIStore } from '@/store/ui-store';
import { readAuthSession } from '@/lib/auth-session';
import { Button } from '@/components/ui/button';
import { Menu } from 'lucide-react';
import { cn } from '@/lib/utils';

export default function Page() {
  const router = useRouter();
  const { isSidebarOpen, toggleSidebar } = useUIStore();
  const [checking, setChecking] = useState(true);

  // Check authentication on mount
  useEffect(() => {
    const session = readAuthSession();
    if (!session || !session.token || session.role === 'guest') {
      router.push('/login');
    } else {
      setChecking(false);
    }
  }, [router]);

  // Show nothing while checking auth
  if (checking) {
    return null;
  }

  return (
    <div className="relative h-screen w-full overflow-hidden bg-background">
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_45%),radial-gradient(circle_at_bottom_right,rgba(14,165,233,0.12),transparent_45%)]" />

      <div className="relative h-full w-full p-3 md:p-5">
        <div className="flex h-full w-full gap-3 md:gap-4">
          {/* Sidebar overlay on mobile */}
          {isSidebarOpen && (
            <div
              className="fixed inset-0 z-40 bg-slate-950/40 md:hidden"
              onClick={toggleSidebar}
              aria-hidden="true"
            />
          )}

          {/* Sidebar */}
          <div className={cn(
              "transition-all duration-300 ease-in-out z-50",
              "md:relative md:z-0",
              isSidebarOpen
                ? "fixed md:relative w-[18.5rem] md:w-[18.5rem]"
                : "w-0 md:w-0 overflow-hidden"
          )}>
            <Sidebar />
          </div>

          {/* Main Content */}
          <div className="flex-1 flex flex-col relative min-w-0 rounded-3xl border border-slate-200/80 bg-white/80 shadow-[0_20px_45px_rgba(15,23,42,0.08)] backdrop-blur overflow-hidden">
            <header className="h-14 border-b border-slate-200/80 flex items-center justify-between px-4 bg-white/70">
                <div className="flex items-center gap-4">
                    <Button
                      variant="ghost"
                      size="icon"
                      onClick={toggleSidebar}
                      aria-label="Toggle sidebar"
                      className="rounded-xl text-slate-700 hover:bg-slate-100"
                    >
                        <Menu className="h-4 w-4" />
                    </Button>
                    <div>
                      <p className="text-[11px] uppercase tracking-[0.16em] text-slate-500">Workspace</p>
                      <h1 className="font-semibold text-slate-900 leading-tight">Starbot</h1>
                    </div>
                </div>
                <AccountMenu />
            </header>

            <main className="flex-1 overflow-hidden relative">
                <ChatView />
                <SettingsPanel />
                <LogsPanel />
            </main>
          </div>
        </div>
      </div>
    </div>
  );
}

layout.tsx

Path: starbot_cloud/src/app/layout.tsx

Size: 1291 bytes

import type { Metadata } from "next";
import { IBM_Plex_Mono, Space_Grotesk } from "next/font/google";
import "./globals.css";
import QueryProvider from "@/components/providers/query-provider";
import ToastProvider from "@/components/providers/toast-provider";
import { ErrorBoundary } from "@/components/error-boundary";
import { NetworkStatus } from "@/components/network-status";

export const dynamic = "force-dynamic";
export const revalidate = 0;
export const fetchCache = "force-no-store";

const spaceGrotesk = Space_Grotesk({
  variable: "--font-space-grotesk",
  subsets: ["latin"],
});

const ibmPlexMono = IBM_Plex_Mono({
  variable: "--font-ibm-plex-mono",
  weight: ["400", "500", "600"],
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Starbot Web GUI",
  description: "Thin client for Starbot API",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${spaceGrotesk.variable} ${ibmPlexMono.variable} antialiased`}
      >
        <ErrorBoundary>
          <QueryProvider>
            {children}
          </QueryProvider>
          <ToastProvider />
          <NetworkStatus />
        </ErrorBoundary>
      </body>
    </html>
  );
}

page.tsx

Path: starbot_cloud/src/app/login/page.tsx

Size: 7311 bytes

'use client';

import { FormEvent, useMemo, useState, useSyncExternalStore } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, KeyRound, Lock, Mail } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { syncServerSession, writeAuthSession } from '@/lib/auth-session';
import { toast } from 'sonner';

function subscribeLocation() {
  return () => {};
}

function getConsoleHosts(): Set<string> {
  const configured = String(process.env.NEXT_PUBLIC_ADMIN_CONSOLE_HOSTS || '')
    .split(',')
    .map((entry) => entry.trim().toLowerCase())
    .filter(Boolean);

  if (configured.length > 0) {
    return new Set(configured);
  }

  return new Set([
    'console.sgauth0.com',
    'www.console.sgauth0.com',
    'console.starbot.cloud',
    'www.console.starbot.cloud',
  ]);
}

function readIsConsoleHost() {
  if (typeof window === 'undefined') return false;
  const host = window.location.hostname.toLowerCase();
  return getConsoleHosts().has(host);
}

function normalizeNextPath(value: string | null, fallback: string): string {
  if (!value) return fallback;
  if (!value.startsWith('/')) return fallback;
  if (value.startsWith('//')) return fallback;
  return value;
}

export default function LoginPage() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [adminCode, setAdminCode] = useState('');
  const isConsoleHost = useSyncExternalStore(subscribeLocation, readIsConsoleHost, () => false);

  const canSubmit = useMemo(() => {
    return email.trim().length > 3 && password.trim().length > 0;
  }, [email, password]);

  const handleLogin = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!canSubmit) return;

    const displayName = email.split('@')[0] || 'User';
    const { role, token } = await syncServerSession({
      name: displayName,
      email: email.trim(),
      adminCode: adminCode.trim() || undefined,
      password: password,
    });

    if (isConsoleHost && role !== 'admin') {
      toast.error('Admin access denied');
      return;
    }

    writeAuthSession({
      name: displayName,
      email: email.trim(),
      loggedInAt: new Date().toISOString(),
      token,
      role,
    });

    toast.success('Signed in');
    const nextPath = normalizeNextPath(searchParams.get('next'), isConsoleHost ? '/admin' : '/');
    if (isConsoleHost) {
      router.push(nextPath);
      return;
    }
    router.push(nextPath);
  };

  return (
    <div className="relative min-h-screen overflow-hidden bg-background">
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_40%),radial-gradient(circle_at_bottom_right,rgba(2,132,199,0.16),transparent_45%)]" />

      <div className="relative mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center p-6">
        <div className="grid w-full max-w-5xl overflow-hidden rounded-3xl border border-slate-200/80 bg-white/85 shadow-[0_24px_55px_rgba(15,23,42,0.10)] md:grid-cols-[1.1fr_1fr]">
          <section className="hidden bg-gradient-to-br from-slate-900 via-slate-800 to-sky-900 p-10 text-slate-100 md:flex md:flex-col md:justify-between">
            <div>
              <p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">Starbot</p>
              <h1 className="mt-4 text-3xl font-semibold leading-tight">Welcome back</h1>
              <p className="mt-3 text-sm text-slate-300">
                Sign in to keep your workspace, settings, and diagnostics experience consistent.
              </p>
            </div>
            <p className="text-xs text-slate-300">Built for focused AI workflows</p>
          </section>

          <section className="p-6 sm:p-8">
            <button
              type="button"
              onClick={() => router.push('/')}
              className="mb-5 inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-100"
            >
              <ArrowLeft className="h-4 w-4" />
              Back
            </button>

            <h2 className="text-2xl font-semibold text-slate-900">Sign in</h2>
            <p className="mt-1 text-sm text-slate-600">
              {isConsoleHost
                ? 'Admin access only. Sign in with your allowlisted email and admin code.'
                : 'Use your account credentials to continue.'}
            </p>

            <form className="mt-6 space-y-4" onSubmit={handleLogin}>
              <label className="block space-y-1">
                <span className="text-xs font-medium uppercase tracking-wide text-slate-500">Email</span>
                <div className="relative">
                  <Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
                  <Input
                    type="email"
                    required
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                    placeholder="you@example.com"
                    className="h-11 rounded-xl border-slate-300 pl-9 focus-visible:ring-slate-400"
                  />
                </div>
              </label>

              <label className="block space-y-1">
                <span className="text-xs font-medium uppercase tracking-wide text-slate-500">Password</span>
                <div className="relative">
                  <Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
                  <Input
                    type="password"
                    required
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                    placeholder="••••••••"
                    className="h-11 rounded-xl border-slate-300 pl-9 focus-visible:ring-slate-400"
                  />
                </div>
              </label>

              <label className="block space-y-1">
                <span className="text-xs font-medium uppercase tracking-wide text-slate-500">Admin access code (optional)</span>
                <div className="relative">
                  <KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
                  <Input
                    type="password"
                    value={adminCode}
                    onChange={(e) => setAdminCode(e.target.value)}
                    placeholder="Required for console admin access"
                    className="h-11 rounded-xl border-slate-300 pl-9 focus-visible:ring-slate-400"
                  />
                </div>
              </label>

              <div className="pt-2">
                <Button
                  type="submit"
                  disabled={!canSubmit}
                  className="h-11 w-full rounded-xl bg-slate-900 text-white hover:bg-slate-800"
                >
                  Sign in
                </Button>
              </div>
            </form>
          </section>
        </div>
      </div>
    </div>
  );
}

page.tsx

Path: starbot_cloud/src/app/signup/page.tsx

Size: 6852 bytes

'use client';

import { FormEvent, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Lock, Mail, UserRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { syncServerSession, writeAuthSession } from '@/lib/auth-session';
import { toast } from 'sonner';

export default function SignupPage() {
  const router = useRouter();
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [confirmPassword, setConfirmPassword] = useState('');

  const canSubmit = useMemo(() => {
    return (
      name.trim().length > 1 &&
      email.trim().length > 3 &&
      password.length >= 6 &&
      password === confirmPassword
    );
  }, [name, email, password, confirmPassword]);

  const handleSignup = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!canSubmit) return;

    // Note: Account creation is disabled. Users must be created in the database.
    toast.error('Account creation is disabled. Please contact the administrator.');
  };

  return (
    <div className="relative min-h-screen overflow-hidden bg-background">
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_40%),radial-gradient(circle_at_bottom_right,rgba(2,132,199,0.16),transparent_45%)]" />

      <div className="relative mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center p-6">
        <div className="grid w-full max-w-5xl overflow-hidden rounded-3xl border border-slate-200/80 bg-white/85 shadow-[0_24px_55px_rgba(15,23,42,0.10)] md:grid-cols-[1.1fr_1fr]">
          <section className="hidden bg-gradient-to-br from-slate-900 via-slate-800 to-sky-900 p-10 text-slate-100 md:flex md:flex-col md:justify-between">
            <div>
              <p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">Starbot</p>
              <h1 className="mt-4 text-3xl font-semibold leading-tight">Create your account</h1>
              <p className="mt-3 text-sm text-slate-300">
                Sign up once to keep your workspace and preferences available on this device.
              </p>
            </div>
            <p className="text-xs text-slate-300">Built for focused AI workflows</p>
          </section>

          <section className="p-6 sm:p-8">
            <button
              type="button"
              onClick={() => router.push('/login')}
              className="mb-5 inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-100"
            >
              <ArrowLeft className="h-4 w-4" />
              Back to login
            </button>

            <h2 className="text-2xl font-semibold text-slate-900">Sign up</h2>
            <p className="mt-1 text-sm text-slate-600">Create your account to continue.</p>

            <form className="mt-6 space-y-4" onSubmit={handleSignup}>
              <label className="block space-y-1">
                <span className="text-xs font-medium uppercase tracking-wide text-slate-500">Name</span>
                <div className="relative">
                  <UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
                  <Input
                    required
                    value={name}
                    onChange={(e) => setName(e.target.value)}
                    placeholder="Your name"
                    className="h-11 rounded-xl border-slate-300 pl-9 focus-visible:ring-slate-400"
                  />
                </div>
              </label>

              <label className="block space-y-1">
                <span className="text-xs font-medium uppercase tracking-wide text-slate-500">Email</span>
                <div className="relative">
                  <Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
                  <Input
                    type="email"
                    required
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                    placeholder="you@example.com"
                    className="h-11 rounded-xl border-slate-300 pl-9 focus-visible:ring-slate-400"
                  />
                </div>
              </label>

              <label className="block space-y-1">
                <span className="text-xs font-medium uppercase tracking-wide text-slate-500">Password</span>
                <div className="relative">
                  <Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
                  <Input
                    type="password"
                    required
                    value={password}
                    onChange={(e) => setPassword(e.target.value)}
                    placeholder="At least 6 characters"
                    className="h-11 rounded-xl border-slate-300 pl-9 focus-visible:ring-slate-400"
                  />
                </div>
              </label>

              <label className="block space-y-1">
                <span className="text-xs font-medium uppercase tracking-wide text-slate-500">Confirm password</span>
                <div className="relative">
                  <Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
                  <Input
                    type="password"
                    required
                    value={confirmPassword}
                    onChange={(e) => setConfirmPassword(e.target.value)}
                    placeholder="Repeat password"
                    className="h-11 rounded-xl border-slate-300 pl-9 focus-visible:ring-slate-400"
                  />
                </div>
              </label>

              <div className="space-y-2 pt-2">
                <Button
                  type="submit"
                  disabled={!canSubmit}
                  className="h-11 w-full rounded-xl bg-slate-900 text-white hover:bg-slate-800"
                >
                  Create account
                </Button>
              </div>

              <p className="pt-1 text-center text-sm text-slate-600">
                Already have an account?{' '}
                <button
                  type="button"
                  onClick={() => router.push('/login')}
                  className="font-medium text-slate-900 underline underline-offset-4 hover:text-slate-700"
                >
                  Sign in
                </button>
              </p>
            </form>
          </section>
        </div>
      </div>
    </div>
  );
}

page.tsx

Path: starbot_cloud/src/app/admin/page.tsx

Size: 7325 bytes

'use client';

import { useMemo } from 'react';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { z } from 'zod';
import { api } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { BadgeCheck, Boxes, Database, IdCard, Shield, Sparkles } from 'lucide-react';

const HealthSchema = z.object({
  status: z.string(),
  timestamp: z.string(),
  version: z.string(),
});

const ProvidersSchema = z.object({
  defaultProvider: z.string(),
  providers: z.array(z.object({
    id: z.string(),
    label: z.string(),
    provider: z.string().optional(),
    model: z.string().optional(),
    tier: z.number().optional(),
    capabilities: z.array(z.string()).optional(),
  })),
});

function formatTimestamp(value?: string): string {
  if (!value) return 'Unknown';
  const parsed = new Date(value);
  if (Number.isNaN(parsed.getTime())) return 'Unknown';
  return parsed.toLocaleString();
}

export default function AdminPage() {
  const healthQuery = useQuery({
    queryKey: ['admin-health'],
    queryFn: () => api.get('/health', HealthSchema),
    refetchInterval: 30_000,
  });

  const providersQuery = useQuery({
    queryKey: ['admin-providers'],
    queryFn: () => api.get('/models', ProvidersSchema),
    refetchInterval: 60_000,
  });

  const providerSummary = useMemo(() => {
    const providers = providersQuery.data?.providers || [];
    const concreteProviders = providers.filter((item) => item.provider);
    const byProvider = new Map<string, number>();

    for (const item of concreteProviders) {
      const key = item.provider!;
      byProvider.set(key, (byProvider.get(key) || 0) + 1);
    }

    return {
      modelCount: concreteProviders.length,
      providerCount: byProvider.size,
      byProvider: Array.from(byProvider.entries()).sort((a, b) => a[0].localeCompare(b[0])),
    };
  }, [providersQuery.data]);

  return (
    <div className="space-y-6">
      <header className="rounded-2xl border border-slate-200/90 bg-gradient-to-r from-slate-50 to-white p-5">
        <p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Overview</p>
        <h1 className="mt-1 text-2xl font-semibold text-slate-900">Admin Control Plane</h1>
        <p className="mt-2 text-sm text-slate-600">
          Runtime status and provider readiness for the admin console host.
        </p>
      </header>

      <section className="grid grid-cols-1 gap-4 md:grid-cols-3">
        <Card className="rounded-2xl border-slate-200/90 bg-white/90">
          <CardHeader className="pb-2">
            <CardDescription className="text-slate-500">API Health</CardDescription>
            <CardTitle className="flex items-center gap-2 text-xl text-slate-900">
              <Shield className="h-5 w-5 text-slate-600" />
              {healthQuery.data?.status?.toUpperCase() || 'UNKNOWN'}
            </CardTitle>
          </CardHeader>
          <CardContent className="space-y-1 text-sm text-slate-600">
            <div>Version: {healthQuery.data?.version || 'n/a'}</div>
            <div>Updated: {formatTimestamp(healthQuery.data?.timestamp)}</div>
          </CardContent>
        </Card>

        <Card className="rounded-2xl border-slate-200/90 bg-white/90">
          <CardHeader className="pb-2">
            <CardDescription className="text-slate-500">Configured Providers</CardDescription>
            <CardTitle className="flex items-center gap-2 text-xl text-slate-900">
              <Boxes className="h-5 w-5 text-slate-600" />
              {providerSummary.providerCount}
            </CardTitle>
          </CardHeader>
          <CardContent className="text-sm text-slate-600">
            Distinct providers with at least one enabled model.
          </CardContent>
        </Card>

        <Card className="rounded-2xl border-slate-200/90 bg-white/90">
          <CardHeader className="pb-2">
            <CardDescription className="text-slate-500">Enabled Models</CardDescription>
            <CardTitle className="flex items-center gap-2 text-xl text-slate-900">
              <Database className="h-5 w-5 text-slate-600" />
              {providerSummary.modelCount}
            </CardTitle>
          </CardHeader>
          <CardContent className="text-sm text-slate-600">
            Total model entries available to routing.
          </CardContent>
        </Card>
      </section>

      <section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
        <Card className="rounded-2xl border-slate-200/90 bg-white/90">
          <CardHeader>
            <CardTitle className="flex items-center gap-2 text-lg text-slate-900">
              <BadgeCheck className="h-5 w-5 text-slate-700" />
              Provider Footprint
            </CardTitle>
            <CardDescription>
              Active provider/model footprint from `/v1/models`.
            </CardDescription>
          </CardHeader>
          <CardContent>
            {providerSummary.byProvider.length === 0 ? (
              <div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-sm text-slate-500">
                No configured providers detected.
              </div>
            ) : (
              <div className="space-y-2">
                {providerSummary.byProvider.map(([provider, count]) => (
                  <div
                    key={provider}
                    className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm"
                  >
                    <span className="font-medium text-slate-800">{provider}</span>
                    <span className="text-slate-600">{count} model{count === 1 ? '' : 's'}</span>
                  </div>
                ))}
              </div>
            )}
          </CardContent>
        </Card>

        <Card className="rounded-2xl border-slate-200/90 bg-white/90">
          <CardHeader>
            <CardTitle className="flex items-center gap-2 text-lg text-slate-900">
              <Sparkles className="h-5 w-5 text-slate-700" />
              Admin Workflows
            </CardTitle>
            <CardDescription>
              Quick access to provider coverage and identity controls.
            </CardDescription>
          </CardHeader>
          <CardContent className="space-y-3">
            <Link
              href="/admin/providers"
              className="inline-flex h-10 w-full items-center justify-start rounded-xl border border-slate-300 bg-white px-4 text-sm font-medium text-slate-700 hover:bg-slate-50"
            >
              Open Provider Inventory
            </Link>
            <Link
              href="/admin/identity"
              className="inline-flex h-10 w-full items-center justify-start rounded-xl border border-slate-300 bg-white px-4 text-sm font-medium text-slate-700 hover:bg-slate-50"
            >
              <IdCard className="mr-2 h-4 w-4" />
              Edit Global Identity
            </Link>
            <p className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-500">
              Chat-specific memory editing remains in the main chat Settings panel.
            </p>
          </CardContent>
        </Card>
      </section>
    </div>
  );
}

layout.tsx

Path: starbot_cloud/src/app/admin/layout.tsx

Size: 206 bytes

import { AdminShell } from '@/components/admin/admin-shell';

export default function AdminLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return <AdminShell>{children}</AdminShell>;
}

page.tsx

Path: starbot_cloud/src/app/admin/identity/page.tsx

Size: 3188 bytes

'use client';

import { useRef } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { IdCard, Save } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Textarea } from '@/components/ui/textarea';
import { memoryApi } from '@/lib/api/memory';

function formatTimestamp(value?: string) {
  if (!value) return 'Never';
  const parsed = new Date(value);
  if (Number.isNaN(parsed.getTime())) return 'Unknown';
  return parsed.toLocaleString();
}

export default function AdminIdentityPage() {
  const editorRef = useRef<HTMLTextAreaElement | null>(null);

  const identityQuery = useQuery({
    queryKey: ['admin-identity'],
    queryFn: memoryApi.getIdentity,
  });

  const saveMutation = useMutation({
    mutationFn: async () => {
      const content = editorRef.current?.value || '';
      await memoryApi.updateIdentity(content);
      await memoryApi.processIdentity();
    },
    onSuccess: async () => {
      await identityQuery.refetch();
      toast.success('Identity saved');
    },
    onError: (error) => {
      toast.error(error instanceof Error ? error.message : 'Failed to save identity');
    },
  });

  return (
    <div className="space-y-6">
      <header className="rounded-2xl border border-slate-200/90 bg-gradient-to-r from-slate-50 to-white p-5">
        <p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Identity</p>
        <h1 className="mt-1 flex items-center gap-2 text-2xl font-semibold text-slate-900">
          <IdCard className="h-6 w-6 text-slate-700" />
          Global `IDENTITY.md`
        </h1>
        <p className="mt-2 text-sm text-slate-600">
          Last updated: {formatTimestamp(identityQuery.data?.updatedAt)}
        </p>
      </header>

      <Card className="rounded-2xl border-slate-200/90 bg-white/90">
        <CardHeader>
          <CardTitle className="text-slate-900">Identity Document</CardTitle>
          <CardDescription>
            This content is shared across all chats and injected into generation context.
          </CardDescription>
        </CardHeader>
        <CardContent className="space-y-3">
          <Textarea
            key={identityQuery.data?.updatedAt || 'identity-default'}
            ref={editorRef}
            defaultValue={identityQuery.data?.content || ''}
            placeholder="Define system identity, behavior constraints, and response style."
            className="min-h-[420px] rounded-xl border-slate-300 font-mono text-sm"
          />
          <div className="flex justify-end">
            <Button
              type="button"
              onClick={() => saveMutation.mutate()}
              disabled={saveMutation.isPending || identityQuery.isLoading}
              className="rounded-xl bg-slate-900 text-white hover:bg-slate-800"
            >
              <Save className="mr-2 h-4 w-4" />
              {saveMutation.isPending ? 'Saving...' : 'Save Identity'}
            </Button>
          </div>
        </CardContent>
      </Card>
    </div>
  );
}

page.tsx

Path: starbot_cloud/src/app/admin/providers/page.tsx

Size: 4235 bytes

'use client';

import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { z } from 'zod';
import { Boxes, Database } from 'lucide-react';
import { api } from '@/lib/api';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';

const ProvidersSchema = z.object({
  defaultProvider: z.string(),
  providers: z.array(z.object({
    id: z.string(),
    label: z.string(),
    provider: z.string().optional(),
    model: z.string().optional(),
    tier: z.number().optional(),
    capabilities: z.array(z.string()).optional(),
  })),
});

export default function AdminProvidersPage() {
  const providersQuery = useQuery({
    queryKey: ['admin-providers-all'],
    queryFn: () => api.get('/models', ProvidersSchema),
    refetchInterval: 60_000,
  });

  const groupedProviders = useMemo(() => {
    const byProvider = new Map<string, z.infer<typeof ProvidersSchema>['providers']>();
    const allProviders = providersQuery.data?.providers || [];

    for (const item of allProviders) {
      if (!item.provider) continue;
      const key = item.provider;
      const next = byProvider.get(key) || [];
      next.push(item);
      byProvider.set(key, next);
    }

    return Array.from(byProvider.entries())
      .map(([provider, models]) => ({
        provider,
        models: models.sort((a, b) => (a.model || '').localeCompare(b.model || '')),
      }))
      .sort((a, b) => a.provider.localeCompare(b.provider));
  }, [providersQuery.data]);

  return (
    <div className="space-y-6">
      <header className="rounded-2xl border border-slate-200/90 bg-gradient-to-r from-slate-50 to-white p-5">
        <p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Providers</p>
        <h1 className="mt-1 text-2xl font-semibold text-slate-900">Model Inventory</h1>
        <p className="mt-2 text-sm text-slate-600">
          Default provider: <span className="font-medium text-slate-800">{providersQuery.data?.defaultProvider || 'n/a'}</span>
        </p>
      </header>

      <section className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
        {groupedProviders.map(({ provider, models }) => (
          <Card key={provider} className="rounded-2xl border-slate-200/90 bg-white/90">
            <CardHeader className="pb-2">
              <CardTitle className="flex items-center gap-2 text-slate-900">
                <Boxes className="h-4 w-4 text-slate-600" />
                {provider}
              </CardTitle>
              <CardDescription>{models.length} model{models.length === 1 ? '' : 's'}</CardDescription>
            </CardHeader>
            <CardContent className="space-y-2">
              {models.map((model) => (
                <div key={model.id} className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700">
                  <div className="font-medium text-slate-900">{model.model || model.label || model.id}</div>
                  <div className="mt-1 flex flex-wrap gap-1">
                    {(model.capabilities || []).slice(0, 5).map((capability) => (
                      <span
                        key={capability}
                        className="rounded-md border border-slate-300 bg-white px-1.5 py-0.5 text-[11px] text-slate-600"
                      >
                        {capability}
                      </span>
                    ))}
                    {typeof model.tier === 'number' && (
                      <span className="rounded-md border border-slate-300 bg-white px-1.5 py-0.5 text-[11px] text-slate-600">
                        tier {model.tier}
                      </span>
                    )}
                  </div>
                </div>
              ))}
            </CardContent>
          </Card>
        ))}
      </section>

      {!providersQuery.isLoading && groupedProviders.length === 0 && (
        <div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
          <Database className="mx-auto mb-2 h-5 w-5 text-slate-400" />
          No provider models were returned by `/v1/models`.
        </div>
      )}
    </div>
  );
}

route.ts

Path: starbot_cloud/src/app/api/auth/session/route.ts

Size: 3514 bytes

import { NextResponse } from 'next/server';
import { z } from 'zod';
import {
  evaluateAdmin,
  getAdminCookieName,
  issueAdminCookieToken,
} from '@/lib/server/admin-cookie';

const SessionBodySchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  adminCode: z.string().optional(),
  password: z.string().optional(),
});

const ADMIN_COOKIE_NAME = getAdminCookieName();
const SESSION_COOKIE_NAME = 'starbot_auth';

// Backend API URL - use environment or default to localhost
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3737';

// Resolve API URL correctly (handle relative paths like /v1)
function resolveApiUrl(endpoint: string): string {
  // If API_URL is a full URL, append the endpoint
  if (API_URL.startsWith('http')) {
    return `${API_URL}${endpoint}`;
  }
  // If API_URL is a relative path like /v1 and endpoint starts with it, use endpoint directly
  // (nginx proxies /v1/* to the API)
  if (endpoint.startsWith(API_URL)) {
    return endpoint;
  }
  // Otherwise just use the endpoint directly
  return endpoint;
}

export async function POST(request: Request) {
  let body: z.infer<typeof SessionBodySchema>;
  try {
    body = SessionBodySchema.parse(await request.json());
  } catch {
    return NextResponse.json({ error: 'Invalid session payload' }, { status: 400 });
  }

  // Password is ALWAYS required - no exceptions
  if (!body.password) {
    return NextResponse.json({ error: 'Password required' }, { status: 400 });
  }

  // Verify against backend API
  let isValidUser = false;
  let backendRole = 'user';
  let backendToken = '';

  try {
    const apiResponse = await fetch(resolveApiUrl('/v1/auth/login'), {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email: body.email,
        password: body.password,
      }),
    });

    if (apiResponse.ok) {
      const data = await apiResponse.json();
      isValidUser = true;
      backendRole = 'user';
      backendToken = data.token || '';
    } else {
      // Password provided but auth failed - reject login
      return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
    }
  } catch (e) {
    // Backend not available - reject login
    return NextResponse.json({ error: 'Backend unavailable' }, { status: 503 });
  }

  // Check admin allowlist (only after password validation passes)
  const isAdmin = evaluateAdmin(body.email, body.adminCode);
  const role = isAdmin ? 'admin' : 'user';
  const response = NextResponse.json({ role, token: backendToken });

  // Set session cookie for server-side auth verification
  response.cookies.set({
    name: SESSION_COOKIE_NAME,
    value: backendToken,
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 60 * 60 * 24 * 7, // 1 week
  });

  if (isAdmin) {
    const token = await issueAdminCookieToken(body.email);
    if (token) {
      response.cookies.set({
        name: ADMIN_COOKIE_NAME,
        value: token,
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'lax',
        path: '/',
        maxAge: 60 * 60 * 8,
      });
    }
  } else {
    response.cookies.delete(ADMIN_COOKIE_NAME);
  }

  return response;
}

export async function DELETE() {
  const response = NextResponse.json({ ok: true });
  response.cookies.delete(ADMIN_COOKIE_NAME);
  response.cookies.delete(SESSION_COOKIE_NAME);
  return response;
}

page.tsx

Path: starbot_cloud/src/app/account/page.tsx

Size: 4045 bytes

'use client';

import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Clock3, Mail, UserRound } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
  AUTH_CHANGED_EVENT,
  clearAuthSession,
  clearServerSession,
  readAuthSession,
  type AuthSession,
} from '@/lib/auth-session';

export default function AccountPage() {
  const router = useRouter();
  const [session, setSession] = useState<AuthSession | null>(null);

  useEffect(() => {
    const refresh = () => setSession(readAuthSession());
    refresh();

    window.addEventListener(AUTH_CHANGED_EVENT, refresh);
    return () => window.removeEventListener(AUTH_CHANGED_EVENT, refresh);
  }, []);

  if (!session) {
    return (
      <main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center p-6">
        <div className="w-full rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
          <h1 className="text-xl font-semibold text-slate-900">No active session</h1>
          <p className="mt-2 text-sm text-slate-600">Sign in or continue as guest to access account details.</p>
          <div className="mt-6">
            <Button onClick={() => router.push('/login')} className="bg-slate-900 text-white hover:bg-slate-800">
              Go to login
            </Button>
          </div>
        </div>
      </main>
    );
  }

  return (
    <main className="mx-auto flex min-h-screen w-full max-w-3xl items-start p-6 pt-12">
      <section className="w-full rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
        <button
          type="button"
          onClick={() => router.push('/')}
          className="mb-6 inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-100"
        >
          <ArrowLeft className="h-4 w-4" />
          Back
        </button>

        <h1 className="text-2xl font-semibold text-slate-900">Account</h1>
        <p className="mt-1 text-sm text-slate-600">Session details for this browser.</p>

        <div className="mt-6 space-y-4">
          <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
            <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Name</p>
            <p className="mt-1 flex items-center gap-2 text-sm text-slate-900">
              <UserRound className="h-4 w-4 text-slate-500" />
              {session.name}
            </p>
          </div>

          <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
            <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Email</p>
            <p className="mt-1 flex items-center gap-2 text-sm text-slate-900">
              <Mail className="h-4 w-4 text-slate-500" />
              {session.email}
            </p>
          </div>

          <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
            <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Signed in at</p>
            <p className="mt-1 flex items-center gap-2 text-sm text-slate-900">
              <Clock3 className="h-4 w-4 text-slate-500" />
              {new Date(session.loggedInAt).toLocaleString()}
            </p>
          </div>

          <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
            <p className="text-xs font-medium uppercase tracking-wide text-slate-500">Role</p>
            <p className="mt-1 text-sm text-slate-900">{session.role || 'user'}</p>
          </div>
        </div>

        <div className="mt-8">
          <Button
            variant="outline"
            className="border-red-300 text-red-600 hover:bg-red-50 hover:text-red-700"
            onClick={async () => {
              await clearServerSession();
              clearAuthSession();
              router.push('/login');
            }}
          >
            Sign out
          </Button>
        </div>
      </section>
    </main>
  );
}

ui-store.ts

Path: starbot_cloud/src/store/ui-store.ts

Size: 1751 bytes

import { create } from 'zustand';
import { Settings } from '@/lib/types';

interface UIState {
  selectedChatId: string | null;
  setSelectedChatId: (id: string | null) => void;

  isSettingsOpen: boolean;
  setSettingsOpen: (open: boolean) => void;
  openSettings: () => void;
  closeSettings: () => void;
  toggleSettings: () => void;

  isLogsOpen: boolean;
  setLogsOpen: (open: boolean) => void;
  openLogs: () => void;
  closeLogs: () => void;
  toggleLogs: () => void;

  isSidebarOpen: boolean;
  toggleSidebar: () => void;

  settings: Settings;
  updateSettings: (settings: Partial<Settings>) => void;

  draftInput: string;
  setDraftInput: (input: string) => void;
}

export const useUIStore = create<UIState>((set) => ({
  selectedChatId: null,
  setSelectedChatId: (id) => set({ selectedChatId: id }),

  isSettingsOpen: false,
  setSettingsOpen: (open) => set({ isSettingsOpen: open }),
  openSettings: () => set({ isSettingsOpen: true }),
  closeSettings: () => set({ isSettingsOpen: false }),
  toggleSettings: () => set((state) => ({ isSettingsOpen: !state.isSettingsOpen })),

  isLogsOpen: false,
  setLogsOpen: (open) => set({ isLogsOpen: open }),
  openLogs: () => set({ isLogsOpen: true }),
  closeLogs: () => set({ isLogsOpen: false }),
  toggleLogs: () => set((state) => ({ isLogsOpen: !state.isLogsOpen })),

  isSidebarOpen: true,
  toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),

  settings: {
    mode: 'standard',
    auto: true,
    speed: false, // false = quality mode, true = fast mode
  },
  updateSettings: (newSettings) =>
    set((state) => ({ settings: { ...state.settings, ...newSettings } })),

  draftInput: '',
  setDraftInput: (input) => set({ draftInput: input }),
}));